aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-08 01:49:52 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-08 01:51:50 +0300
commite4e14142b1d9708dd582c99555eeab342c057eba (patch)
treea66f62f0b04be63396a8026ea191de832c2f908b /src/app
parent216aa99d5d911d6f2e16b4ebf9f1d40fc2e96474 (diff)
feat: add resizable sidebar sections
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs6
-rw-r--r--src/app/input.rs333
-rw-r--r--src/app/mod.rs88
-rw-r--r--src/app/state.rs9
4 files changed, 371 insertions, 65 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<ScrollbarClickTarget> {
+ 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<usize> {
+ 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<usize> {
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,
@@ -3597,6 +3762,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();
let mut ws = Workspace::test_new("test");
@@ -3776,6 +4037,28 @@ mod tests {
}
#[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();
app.state.default_sidebar_width = 26;
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<bool>,
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<ReleaseNotesState>,
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<DragState>,
@@ -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,