From e4e14142b1d9708dd582c99555eeab342c057eba Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 8 Apr 2026 01:49:52 +0300 Subject: [PATCH] feat: add resizable sidebar sections --- src/app/actions.rs | 6 + src/app/input.rs | 333 ++++++++++++++++-- src/app/mod.rs | 88 ++--- src/app/state.rs | 9 + src/persist.rs | 14 + src/ui.rs | 177 +++++++--- .../session/current-herdr-dev-session.json | 3 +- 7 files changed, 522 insertions(+), 108 deletions(-) diff --git a/src/app/actions.rs b/src/app/actions.rs index ac9b9243..fff092d3 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -106,6 +106,12 @@ impl AppState { if idx < self.workspaces.len() { self.active = Some(idx); self.selected = idx; + if matches!( + self.agent_panel_scope, + crate::app::state::AgentPanelScope::CurrentWorkspace + ) { + self.agent_panel_scroll = 0; + } self.ensure_workspace_visible(idx); if let Some(ws) = self.workspaces.get_mut(idx) { ws.switch_tab(ws.active_tab); diff --git a/src/app/input.rs b/src/app/input.rs index e483029b..56dc97c9 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -1431,7 +1431,17 @@ impl AppState { if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { return Rect::default(); } - crate::ui::workspace_list_rect(sidebar) + crate::ui::workspace_list_rect(sidebar, self.sidebar_section_split) + } + + fn agent_panel_rect(&self) -> Rect { + let sidebar = self.view.sidebar_rect; + if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 { + return Rect::default(); + } + let (_, detail_area) = + crate::ui::expanded_sidebar_sections(sidebar, self.sidebar_section_split); + detail_area } fn workspace_list_scrollbar_target_at( @@ -1498,6 +1508,61 @@ impl AppState { } } + fn agent_panel_scrollbar_target_at(&self, col: u16, row: u16) -> Option { + let area = self.agent_panel_rect(); + let metrics = crate::ui::agent_panel_scroll_metrics(self, area); + let track = crate::ui::agent_panel_scrollbar_rect(self, area)?; + if col < track.x + || col >= track.x + track.width + || row < track.y + || row >= track.y + track.height + { + return None; + } + if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) { + Some(ScrollbarClickTarget::Thumb { grab_row_offset }) + } else { + Some(ScrollbarClickTarget::Track { + offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row), + }) + } + } + + fn agent_panel_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option { + let area = self.agent_panel_rect(); + let metrics = crate::ui::agent_panel_scroll_metrics(self, area); + let track = crate::ui::agent_panel_scrollbar_rect(self, area)?; + Some(crate::ui::scrollbar_offset_from_drag_row( + metrics, + track, + row, + grab_row_offset, + )) + } + + fn set_agent_panel_offset_from_bottom(&mut self, offset_from_bottom: usize) { + let area = self.agent_panel_rect(); + let metrics = crate::ui::agent_panel_scroll_metrics(self, area); + self.agent_panel_scroll = metrics + .max_offset_from_bottom + .saturating_sub(offset_from_bottom); + } + + fn scroll_agent_panel(&mut self, delta: i16) { + let area = self.agent_panel_rect(); + let max_scroll = crate::ui::agent_panel_scroll_metrics(self, area).max_offset_from_bottom; + if delta.is_negative() { + self.agent_panel_scroll = self + .agent_panel_scroll + .saturating_sub(delta.unsigned_abs() as usize); + } else { + self.agent_panel_scroll = self + .agent_panel_scroll + .saturating_add(delta as usize) + .min(max_scroll); + } + } + pub(crate) fn sidebar_footer_rect(&self) -> Rect { let ws_area = self.workspace_list_rect(); if ws_area == Rect::default() { @@ -1923,6 +1988,14 @@ impl AppState { return None; } + if self.on_sidebar_section_divider(mouse.column, mouse.row) { + self.drag = Some(DragState { + target: DragTarget::SidebarSectionDivider, + }); + self.set_sidebar_section_split(mouse.row); + return None; + } + if !in_sidebar { if let Some(border) = self.find_border_at(mouse.column, mouse.row) { self.drag = Some(DragState { @@ -2035,6 +2108,23 @@ impl AppState { AgentPanelScope::CurrentWorkspace => AgentPanelScope::AllWorkspaces, AgentPanelScope::AllWorkspaces => AgentPanelScope::CurrentWorkspace, }; + self.agent_panel_scroll = 0; + return None; + } + + if let Some(target) = + self.agent_panel_scrollbar_target_at(mouse.column, mouse.row) + { + match target { + ScrollbarClickTarget::Thumb { grab_row_offset } => { + self.drag = Some(DragState { + target: DragTarget::AgentPanelScrollbar { grab_row_offset }, + }); + } + ScrollbarClickTarget::Track { offset_from_bottom } => { + self.set_agent_panel_offset_from_bottom(offset_from_bottom); + } + } return None; } @@ -2125,6 +2215,13 @@ impl AppState { self.set_workspace_list_offset_from_bottom(offset_from_bottom); } } + DragTarget::AgentPanelScrollbar { grab_row_offset } => { + if let Some(offset_from_bottom) = + self.agent_panel_offset_for_drag_row(mouse.row, *grab_row_offset) + { + self.set_agent_panel_offset_from_bottom(offset_from_bottom); + } + } DragTarget::PaneSplit { path, direction, @@ -2161,6 +2258,9 @@ impl AppState { DragTarget::SidebarDivider => { self.set_manual_sidebar_width(mouse.column); } + DragTarget::SidebarSectionDivider => { + self.set_sidebar_section_split(mouse.row); + } DragTarget::ReleaseNotesScrollbar { .. } | DragTarget::KeybindHelpScrollbar { .. } => {} } @@ -2224,10 +2324,19 @@ impl AppState { } MouseEventKind::ScrollUp if in_sidebar => { - if crate::ui::should_show_scrollbar(crate::ui::workspace_list_scroll_metrics( - self, - self.workspace_list_rect(), - )) { + let agent_area = self.agent_panel_rect(); + let over_agent_panel = agent_area != Rect::default() + && mouse.row >= agent_area.y + && mouse.row < agent_area.y + agent_area.height; + if over_agent_panel { + if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( + self, agent_area, + )) { + self.scroll_agent_panel(-1); + } + } else if crate::ui::should_show_scrollbar( + crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), + ) { self.scroll_workspace_list(-1); } else if self.selected > 0 { self.selected -= 1; @@ -2235,10 +2344,19 @@ impl AppState { } } MouseEventKind::ScrollDown if in_sidebar => { - if crate::ui::should_show_scrollbar(crate::ui::workspace_list_scroll_metrics( - self, - self.workspace_list_rect(), - )) { + let agent_area = self.agent_panel_rect(); + let over_agent_panel = agent_area != Rect::default() + && mouse.row >= agent_area.y + && mouse.row < agent_area.y + agent_area.height; + if over_agent_panel { + if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( + self, agent_area, + )) { + self.scroll_agent_panel(1); + } + } else if crate::ui::should_show_scrollbar( + crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()), + ) { self.scroll_workspace_list(1); } else if !self.workspaces.is_empty() && self.selected < self.workspaces.len() - 1 { self.selected += 1; @@ -2340,6 +2458,32 @@ impl AppState { width.clamp(crate::ui::MIN_SIDEBAR_WIDTH, crate::ui::MAX_SIDEBAR_WIDTH); } + fn on_sidebar_section_divider(&self, col: u16, row: u16) -> bool { + if self.sidebar_collapsed { + return false; + } + let rect = crate::ui::sidebar_section_divider_rect( + self.view.sidebar_rect, + self.sidebar_section_split, + ); + rect.width > 0 + && col >= rect.x + && col < rect.x + rect.width + && row >= rect.y + && row < rect.y + rect.height + } + + fn set_sidebar_section_split(&mut self, row: u16) { + let sidebar = self.view.sidebar_rect; + let content_height = sidebar.height; + if content_height < 6 { + return; + } + let relative_y = row.saturating_sub(sidebar.y); + let ratio = (relative_y as f32) / (content_height as f32); + self.sidebar_section_split = ratio.clamp(0.1, 0.9); + } + /// Find which workspace index a sidebar row belongs to (two-section layout). fn tab_at(&self, col: u16, row: u16) -> Option { self.view @@ -2487,7 +2631,10 @@ impl AppState { return false; } - let (_, detail_area) = crate::ui::expanded_sidebar_sections(self.view.sidebar_rect); + let (_, detail_area) = crate::ui::expanded_sidebar_sections( + self.view.sidebar_rect, + self.sidebar_section_split, + ); let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_scope); rect.width > 0 && col >= rect.x @@ -2501,23 +2648,33 @@ impl AppState { return None; } - let (_, detail_area) = crate::ui::expanded_sidebar_sections(self.view.sidebar_rect); - if detail_area.height < 4 - || row < detail_area.y + 3 - || row >= detail_area.y + detail_area.height - { + let detail_area = self.agent_panel_rect(); + let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area); + let body = crate::ui::agent_panel_body_rect( + detail_area, + crate::ui::should_show_scrollbar(metrics), + ); + if body.height < 2 || row < body.y || row >= body.y + body.height { return None; } - let relative_row = row - (detail_area.y + 3); - let entry_height = 3; - if relative_row % entry_height == 2 { - return None; + let mut row_y = body.y; + for detail in crate::ui::agent_panel_entries(self) + .into_iter() + .skip(self.agent_panel_scroll) + { + if row_y.saturating_add(1) >= body.y + body.height { + break; + } + if row == row_y || row == row_y + 1 { + return Some((detail.ws_idx, detail.tab_idx, detail.pane_id)); + } + row_y = row_y.saturating_add(2); + if row_y < body.y + body.height { + row_y = row_y.saturating_add(1); + } } - let detail_idx = (relative_row / entry_height) as usize; - let details = crate::ui::agent_panel_entries(self); - let detail = details.get(detail_idx)?; - Some((detail.ws_idx, detail.tab_idx, detail.pane_id)) + None } fn screen_rect(&self) -> Rect { @@ -3543,8 +3700,12 @@ mod tests { app.state.active = Some(0); app.state.selected = 0; app.state.mode = Mode::Terminal; + app.state.agent_panel_scroll = 3; - let (_, detail_area) = crate::ui::expanded_sidebar_sections(app.state.view.sidebar_rect); + let (_, detail_area) = crate::ui::expanded_sidebar_sections( + app.state.view.sidebar_rect, + app.state.sidebar_section_split, + ); let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_scope); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), @@ -3553,6 +3714,7 @@ mod tests { )); assert_eq!(app.state.agent_panel_scope, AgentPanelScope::AllWorkspaces); + assert_eq!(app.state.agent_panel_scroll, 0); } #[test] @@ -3580,7 +3742,10 @@ mod tests { app.state.mode = Mode::Terminal; app.state.agent_panel_scope = AgentPanelScope::AllWorkspaces; - let (_, detail_area) = crate::ui::expanded_sidebar_sections(app.state.view.sidebar_rect); + let (_, detail_area) = crate::ui::expanded_sidebar_sections( + app.state.view.sidebar_rect, + app.state.sidebar_section_split, + ); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), detail_area.x + 2, @@ -3596,6 +3761,102 @@ mod tests { ); } + #[test] + fn scrolling_agent_panel_with_wheel_updates_agent_panel_scroll() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let first_pane = ws.tabs[0].root_pane; + ws.tabs[0] + .panes + .get_mut(&first_pane) + .unwrap() + .detected_agent = Some(Agent::Pi); + + for (tab_name, agent) in [ + ("logs", Agent::Claude), + ("review", Agent::Codex), + ("ops", Agent::Gemini), + ] { + let tab_idx = ws.test_add_tab(Some(tab_name)); + let pane_id = ws.tabs[tab_idx].root_pane; + ws.tabs[tab_idx] + .panes + .get_mut(&pane_id) + .unwrap() + .detected_agent = Some(agent); + } + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let detail_area = app.state.agent_panel_rect(); + assert!(crate::ui::should_show_scrollbar( + crate::ui::agent_panel_scroll_metrics(&app.state, detail_area) + )); + + app.handle_mouse(mouse( + MouseEventKind::ScrollDown, + detail_area.x + 1, + detail_area.y + 4, + )); + + assert_eq!(app.state.agent_panel_scroll, 1); + assert_eq!(app.state.selected, 0); + } + + #[test] + fn clicking_scrolled_agent_detail_row_switches_to_correct_tab_and_pane() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let first_pane = ws.tabs[0].root_pane; + ws.tabs[0] + .panes + .get_mut(&first_pane) + .unwrap() + .detected_agent = Some(Agent::Pi); + + let second_tab = ws.test_add_tab(Some("logs")); + let second_pane = ws.tabs[second_tab].root_pane; + ws.tabs[second_tab] + .panes + .get_mut(&second_pane) + .unwrap() + .detected_agent = Some(Agent::Claude); + + for (tab_name, agent) in [("review", Agent::Codex), ("ops", Agent::Gemini)] { + let tab_idx = ws.test_add_tab(Some(tab_name)); + let pane_id = ws.tabs[tab_idx].root_pane; + ws.tabs[tab_idx] + .panes + .get_mut(&pane_id) + .unwrap() + .detected_agent = Some(agent); + } + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.agent_panel_scroll = 1; + + let detail_area = app.state.agent_panel_rect(); + let body = crate::ui::agent_panel_body_rect(detail_area, true); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + body.x + 1, + body.y, + )); + + assert_eq!(app.state.workspaces[0].active_tab, second_tab); + assert_eq!( + app.state.workspaces[0].tabs[second_tab].layout.focused(), + second_pane + ); + assert_eq!(app.state.mode, Mode::Terminal); + } + #[test] fn clicking_collapsed_agent_row_switches_to_correct_tab_and_pane() { let mut app = app_for_mouse_test(); @@ -3775,6 +4036,28 @@ mod tests { assert_eq!(app.state.sidebar_width, 31); } + #[test] + fn dragging_sidebar_section_divider_sets_split_ratio() { + let mut app = app_for_mouse_test(); + let divider = crate::ui::sidebar_section_divider_rect( + app.state.view.sidebar_rect, + app.state.sidebar_section_split, + ); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + divider.x + 1, + divider.y, + )); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + divider.x + 1, + divider.y + 4, + )); + + assert!(app.state.sidebar_section_split > 0.5); + } + #[test] fn double_clicking_sidebar_divider_resets_default_width() { let mut app = app_for_mouse_test(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 344a1a66..74a873e0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -157,54 +157,59 @@ impl App { let render_dirty = Arc::new(AtomicBool::new(false)); // Try to restore previous session - let (workspaces, active, selected, agent_panel_scope, sidebar_width) = if no_session { - ( - Vec::new(), - None, - 0, - state::AgentPanelScope::CurrentWorkspace, - config.ui.sidebar_width, - ) - } else if let Some(snap) = crate::persist::load() { - let ws = crate::persist::restore( - &snap, - 24, - 80, - config.advanced.scrollback_limit_bytes, - event_tx.clone(), - render_notify.clone(), - render_dirty.clone(), - ); - if ws.is_empty() { - info!("session file found but no workspaces restored"); + let (workspaces, active, selected, agent_panel_scope, sidebar_width, sidebar_section_split) = + if no_session { ( Vec::new(), None, 0, - snap.agent_panel_scope, - snap.sidebar_width.unwrap_or(config.ui.sidebar_width), + state::AgentPanelScope::CurrentWorkspace, + config.ui.sidebar_width, + 0.5_f32, ) + } else if let Some(snap) = crate::persist::load() { + let ws = crate::persist::restore( + &snap, + 24, + 80, + config.advanced.scrollback_limit_bytes, + event_tx.clone(), + render_notify.clone(), + render_dirty.clone(), + ); + if ws.is_empty() { + info!("session file found but no workspaces restored"); + ( + Vec::new(), + None, + 0, + snap.agent_panel_scope, + snap.sidebar_width.unwrap_or(config.ui.sidebar_width), + snap.sidebar_section_split.unwrap_or(0.5), + ) + } else { + info!(count = ws.len(), "session restored"); + let active = snap.active.filter(|&i| i < ws.len()); + let selected = snap.selected.min(ws.len().saturating_sub(1)); + ( + ws, + active, + selected, + snap.agent_panel_scope, + snap.sidebar_width.unwrap_or(config.ui.sidebar_width), + snap.sidebar_section_split.unwrap_or(0.5), + ) + } } else { - info!(count = ws.len(), "session restored"); - let active = snap.active.filter(|&i| i < ws.len()); - let selected = snap.selected.min(ws.len().saturating_sub(1)); ( - ws, - active, - selected, - snap.agent_panel_scope, - snap.sidebar_width.unwrap_or(config.ui.sidebar_width), + Vec::new(), + None, + 0, + state::AgentPanelScope::CurrentWorkspace, + config.ui.sidebar_width, + 0.5_f32, ) - } - } else { - ( - Vec::new(), - None, - 0, - state::AgentPanelScope::CurrentWorkspace, - config.ui.sidebar_width, - ) - }; + }; info!( pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes, @@ -246,6 +251,7 @@ impl App { }), keybind_help: state::KeybindHelpState { scroll: 0 }, workspace_scroll: 0, + agent_panel_scroll: 0, view: state::ViewState { sidebar_rect: Rect::default(), workspace_card_areas: Vec::new(), @@ -271,6 +277,7 @@ impl App { sidebar_width, sidebar_width_auto: false, sidebar_collapsed: false, + sidebar_section_split, agent_panel_scope, confirm_close: config.ui.confirm_close, pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes, @@ -457,6 +464,7 @@ impl App { self.state.selected, self.state.agent_panel_scope, self.state.sidebar_width, + self.state.sidebar_section_split, ); crate::persist::save(&snap); } diff --git a/src/app/state.rs b/src/app/state.rs index a0955a16..2904b206 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -476,6 +476,9 @@ pub(crate) enum DragTarget { WorkspaceListScrollbar { grab_row_offset: u16, }, + AgentPanelScrollbar { + grab_row_offset: u16, + }, PaneSplit { path: Vec, direction: Direction, @@ -492,6 +495,7 @@ pub(crate) enum DragTarget { grab_row_offset: u16, }, SidebarDivider, + SidebarSectionDivider, } /// Active mouse drag on a split border or sidebar divider. @@ -580,6 +584,7 @@ pub struct AppState { pub release_notes: Option, pub keybind_help: KeybindHelpState, pub workspace_scroll: usize, + pub agent_panel_scroll: usize, // View geometry (computed before render, consumed by render + mouse) pub view: ViewState, pub(crate) drag: Option, @@ -599,6 +604,8 @@ pub struct AppState { pub sidebar_width: u16, pub sidebar_width_auto: bool, pub sidebar_collapsed: bool, + /// Ratio of sidebar height allocated to the workspaces section. + pub sidebar_section_split: f32, pub agent_panel_scope: AgentPanelScope, pub confirm_close: bool, pub pane_scrollback_limit_bytes: usize, @@ -684,6 +691,7 @@ impl AppState { release_notes: None, keybind_help: KeybindHelpState { scroll: 0 }, workspace_scroll: 0, + agent_panel_scroll: 0, view: ViewState { sidebar_rect: Rect::default(), workspace_card_areas: Vec::new(), @@ -709,6 +717,7 @@ impl AppState { sidebar_width: 26, sidebar_width_auto: false, sidebar_collapsed: false, + sidebar_section_split: 0.5, agent_panel_scope: AgentPanelScope::CurrentWorkspace, confirm_close: true, pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES, diff --git a/src/persist.rs b/src/persist.rs index 480c1248..06c5af61 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -34,6 +34,8 @@ pub struct SessionSnapshot { pub agent_panel_scope: crate::app::state::AgentPanelScope, #[serde(default)] pub sidebar_width: Option, + #[serde(default)] + pub sidebar_section_split: Option, } #[derive(Serialize, Deserialize)] @@ -133,6 +135,8 @@ struct RawSessionSnapshot { agent_panel_scope: crate::app::state::AgentPanelScope, #[serde(default)] sidebar_width: Option, + #[serde(default)] + sidebar_section_split: Option, } fn migrate_snapshot(raw: RawSessionSnapshot) -> Result { @@ -147,6 +151,7 @@ fn migrate_snapshot(raw: RawSessionSnapshot) -> Result selected: raw.selected, agent_panel_scope: raw.agent_panel_scope, sidebar_width: raw.sidebar_width, + sidebar_section_split: raw.sidebar_section_split, }) } @@ -205,6 +210,7 @@ pub fn capture( selected: usize, agent_panel_scope: crate::app::state::AgentPanelScope, sidebar_width: u16, + sidebar_section_split: f32, ) -> SessionSnapshot { SessionSnapshot { version: SNAPSHOT_VERSION, @@ -213,6 +219,7 @@ pub fn capture( selected, agent_panel_scope, sidebar_width: Some(sidebar_width), + sidebar_section_split: Some(sidebar_section_split), } } @@ -580,12 +587,14 @@ mod tests { selected: 0, agent_panel_scope: crate::app::state::AgentPanelScope::CurrentWorkspace, sidebar_width: Some(26), + sidebar_section_split: Some(0.5), }; let json = serde_json::to_string(&snap).unwrap(); let restored = parse_snapshot(&json).unwrap(); assert!(restored.workspaces.is_empty()); assert_eq!(restored.active, None); assert_eq!(restored.sidebar_width, Some(26)); + assert_eq!(restored.sidebar_section_split, Some(0.5)); } #[test] @@ -651,6 +660,7 @@ mod tests { selected: 0, agent_panel_scope: crate::app::state::AgentPanelScope::CurrentWorkspace, sidebar_width: Some(26), + sidebar_section_split: Some(0.5), version: SNAPSHOT_VERSION, }; @@ -674,6 +684,7 @@ mod tests { crate::app::state::AgentPanelScope::CurrentWorkspace ); assert_eq!(restored.sidebar_width, Some(26)); + assert_eq!(restored.sidebar_section_split, Some(0.5)); } #[test] @@ -689,6 +700,7 @@ mod tests { crate::app::state::AgentPanelScope::CurrentWorkspace ); assert_eq!(snap.sidebar_width, None); + assert_eq!(snap.sidebar_section_split, None); assert_eq!(snap.workspaces[0].tabs.len(), 2); assert_eq!( snap.workspaces[1].identity_cwd, @@ -706,6 +718,7 @@ mod tests { snap.agent_panel_scope, crate::app::state::AgentPanelScope::CurrentWorkspace ); + assert_eq!(snap.sidebar_section_split, Some(0.4)); assert_eq!(snap.workspaces[0].active_tab, 1); assert_eq!(snap.workspaces[1].tabs[0].panes.len(), 2); } @@ -727,6 +740,7 @@ mod tests { crate::app::state::AgentPanelScope::CurrentWorkspace ); assert_eq!(restored.sidebar_width, None); + assert_eq!(restored.sidebar_section_split, None); } #[test] diff --git a/src/ui.rs b/src/ui.rs index 4a734d0d..4540f20f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -37,26 +37,45 @@ pub(crate) struct AgentPanelEntry { pub seen: bool, } -pub(crate) fn expanded_sidebar_sections(area: Rect) -> (Rect, Rect) { +fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) { + if total_h == 0 { + return (0, 0); + } + + if total_h < 6 { + let ws_h = (total_h + 1) / 2; + return (ws_h, total_h.saturating_sub(ws_h)); + } + + let ratio = split_ratio.clamp(0.1, 0.9); + let ws_h = ((total_h as f32) * ratio).round() as u16; + let ws_h = ws_h.clamp(3, total_h.saturating_sub(3)); + let detail_h = total_h.saturating_sub(ws_h); + (ws_h, detail_h) +} + +pub(crate) fn expanded_sidebar_sections(area: Rect, split_ratio: f32) -> (Rect, Rect) { let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); if content.width == 0 || content.height == 0 { return (Rect::default(), Rect::default()); } - let total_h = content.height as usize; - let ws_h = (total_h + 1) / 2; - let detail_h = total_h.saturating_sub(ws_h); - - let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16); - let detail_area = Rect::new( - content.x, - content.y + ws_h as u16, - content.width, - detail_h as u16, - ); + let (ws_h, detail_h) = sidebar_section_heights(content.height, split_ratio); + let ws_area = Rect::new(content.x, content.y, content.width, ws_h); + let detail_area = Rect::new(content.x, content.y + ws_h, content.width, detail_h); (ws_area, detail_area) } +pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect { + let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); + if content.width == 0 || content.height < 6 { + return Rect::default(); + } + + let (ws_h, _) = sidebar_section_heights(content.height, split_ratio); + Rect::new(content.x, content.y + ws_h, content.width, 1) +} + fn agent_panel_current_workspace_idx(app: &AppState) -> Option { if matches!( app.mode, @@ -228,6 +247,13 @@ pub fn compute_view(app: &mut AppState, area: Rect) { app.workspace_scroll = app .workspace_scroll .min(app.workspaces.len().saturating_sub(1)); + if !app.sidebar_collapsed { + let (_, detail_area) = expanded_sidebar_sections(sidebar_area, app.sidebar_section_split); + let max_agent_scroll = agent_panel_scroll_metrics(app, detail_area).max_offset_from_bottom; + app.agent_panel_scroll = app.agent_panel_scroll.min(max_agent_scroll); + } else { + app.agent_panel_scroll = 0; + } let workspace_card_areas = if app.sidebar_collapsed { Vec::new() @@ -311,6 +337,7 @@ pub fn render(app: &AppState, frame: &mut Frame) { const MIN_TAB_WIDTH: u16 = 8; const NEW_TAB_WIDTH: u16 = 3; const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2; +const AGENT_PANEL_HEADER_ROWS: u16 = 3; fn workspace_row_height(ws: &crate::workspace::Workspace) -> u16 { if ws.branch().is_some() { @@ -320,15 +347,9 @@ fn workspace_row_height(ws: &crate::workspace::Workspace) -> u16 { } } -pub(crate) fn workspace_list_rect(area: Rect) -> Rect { - let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); - if content.width == 0 || content.height == 0 { - return Rect::default(); - } - - let total_h = content.height as usize; - let ws_h = (total_h + 1) / 2; - Rect::new(content.x, content.y, content.width, ws_h as u16) +pub(crate) fn workspace_list_rect(area: Rect, split_ratio: f32) -> Rect { + let (ws_area, _) = expanded_sidebar_sections(area, split_ratio); + ws_area } pub(crate) fn workspace_list_body_rect(area: Rect, has_scrollbar: bool) -> Rect { @@ -391,11 +412,66 @@ pub(crate) fn workspace_list_scrollbar_rect(app: &AppState, area: Rect) -> Optio )) } +pub(crate) fn agent_panel_body_rect(area: Rect, has_scrollbar: bool) -> Rect { + if area.width == 0 || area.height <= AGENT_PANEL_HEADER_ROWS { + return Rect::default(); + } + + let body_y = area.y.saturating_add(AGENT_PANEL_HEADER_ROWS); + let body_height = (area.y + area.height).saturating_sub(body_y); + let body_width = area.width.saturating_sub(u16::from(has_scrollbar)); + Rect::new(area.x, body_y, body_width, body_height) +} + +fn agent_panel_visible_count(area: Rect) -> usize { + let body = agent_panel_body_rect(area, false); + if body.width == 0 || body.height < 2 { + return 0; + } + + let mut used_rows = 0u16; + let mut visible = 0usize; + while used_rows.saturating_add(2) <= body.height { + used_rows = used_rows.saturating_add(2); + visible += 1; + if used_rows < body.height { + used_rows = used_rows.saturating_add(1); + } + } + visible +} + +pub(crate) fn agent_panel_scroll_metrics(app: &AppState, area: Rect) -> crate::pane::ScrollMetrics { + let viewport_rows = agent_panel_visible_count(area); + let total_rows = agent_panel_entries(app).len(); + let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); + let offset_from_bottom = total_rows + .saturating_sub(app.agent_panel_scroll) + .saturating_sub(viewport_rows); + + crate::pane::ScrollMetrics { + offset_from_bottom, + max_offset_from_bottom, + viewport_rows, + } +} + +pub(crate) fn agent_panel_scrollbar_rect(app: &AppState, area: Rect) -> Option { + let metrics = agent_panel_scroll_metrics(app, area); + let body = agent_panel_body_rect(area, true); + (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new( + area.x + area.width.saturating_sub(1), + body.y, + 1, + body.height, + )) +} + pub(crate) fn compute_workspace_card_areas( app: &AppState, area: Rect, ) -> Vec { - let ws_area = workspace_list_rect(area); + let ws_area = workspace_list_rect(area, app.sidebar_section_split); if ws_area == Rect::default() { return Vec::new(); } @@ -760,7 +836,7 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { buf[(sep_x, y)].set_style(sep_style); } - let (ws_area, detail_area) = expanded_sidebar_sections(area); + let (ws_area, detail_area) = expanded_sidebar_sections(area, app.sidebar_section_split); // --- Top section: Workspaces --- render_workspace_list(app, frame, ws_area, is_navigating); @@ -970,22 +1046,18 @@ fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) { return; } - let mut row_y = area.y; - - // Horizontal separator let sep_line = "─".repeat(area.width as usize); frame.render_widget( Paragraph::new(Span::styled(&sep_line, Style::default().fg(p.surface_dim))), - Rect::new(area.x, row_y, area.width, 1), + Rect::new(area.x, area.y, area.width, 1), ); - row_y += 1; frame.render_widget( Paragraph::new(Line::from(vec![Span::styled( " agents", Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD), )])), - Rect::new(area.x, row_y, area.width, 1), + Rect::new(area.x, area.y + 1, area.width, 1), ); let toggle_rect = agent_panel_toggle_rect(area, app.agent_panel_scope); if toggle_rect != Rect::default() { @@ -998,13 +1070,19 @@ fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) { toggle_rect, ); } - row_y += 1; - // Blank line for breathing room - row_y += 1; + let details = agent_panel_entries(app); + let metrics = agent_panel_scroll_metrics(app, area); + let scrollbar_rect = agent_panel_scrollbar_rect(app, area); + let body = agent_panel_body_rect(area, should_show_scrollbar(metrics)); + if body == Rect::default() { + return; + } - for detail in &agent_panel_entries(app) { - if row_y + 1 >= area.y + area.height { + let mut row_y = body.y; + let body_bottom = body.y + body.height; + for detail in details.iter().skip(app.agent_panel_scroll) { + if row_y.saturating_add(1) >= body_bottom { break; } @@ -1017,7 +1095,7 @@ fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) { let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM); let primary_label = - format_agent_panel_primary_label(detail, area.width.saturating_sub(3) as usize); + format_agent_panel_primary_label(detail, body.width.saturating_sub(3) as usize); let name_line = Line::from(vec![ Span::styled(" ", Style::default()), Span::styled(icon, icon_style), @@ -1026,14 +1104,10 @@ fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) { ]); frame.render_widget( Paragraph::new(name_line), - Rect::new(area.x, row_y, area.width, 1), + Rect::new(body.x, row_y, body.width, 1), ); row_y += 1; - if row_y >= area.y + area.height { - break; - } - let mut status_spans = vec![ Span::styled(" ", Style::default()), Span::styled(label, status_style), @@ -1044,14 +1118,18 @@ fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) { } frame.render_widget( Paragraph::new(Line::from(status_spans)), - Rect::new(area.x, row_y, area.width, 1), + Rect::new(body.x, row_y, body.width, 1), ); row_y += 1; - if row_y < area.y + area.height { + if row_y < body_bottom { row_y += 1; } } + + if let Some(track) = scrollbar_rect { + render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕"); + } } pub(crate) fn collapsed_sidebar_toggle_rect(area: Rect) -> Rect { @@ -3004,6 +3082,21 @@ mod tests { assert_eq!(label, "agent-bro… · test…"); } + #[test] + fn expanded_sidebar_sections_handle_tiny_heights() { + let (ws_area, detail_area) = expanded_sidebar_sections(Rect::new(0, 0, 20, 5), 0.9); + + assert_eq!(ws_area, Rect::new(0, 0, 19, 3)); + assert_eq!(detail_area, Rect::new(0, 3, 19, 2)); + } + + #[test] + fn sidebar_section_divider_is_hidden_for_tiny_heights() { + let divider = sidebar_section_divider_rect(Rect::new(0, 0, 20, 5), 0.5); + + assert_eq!(divider, Rect::default()); + } + #[test] fn pane_scrollbar_rect_uses_rightmost_inner_column() { let info = PaneInfo { diff --git a/tests/fixtures/session/current-herdr-dev-session.json b/tests/fixtures/session/current-herdr-dev-session.json index 0721acb9..1570ebc4 100644 --- a/tests/fixtures/session/current-herdr-dev-session.json +++ b/tests/fixtures/session/current-herdr-dev-session.json @@ -74,5 +74,6 @@ ], "active": 0, "selected": 0, - "agent_panel_scope": "CurrentWorkspace" + "agent_panel_scope": "CurrentWorkspace", + "sidebar_section_split": 0.4 }