From a292650d38cea17001feba67172a0a5731106d7f Mon Sep 17 00:00:00 2001 From: qianqian <1433687451@qq.com> Date: Fri, 14 Aug 2026 04:15:26 +0800 Subject: [PATCH 1/3] feat(config): add ui.sidebar.wheel config (switch|scroll) --- src/app/mod.rs | 2 ++ src/app/state.rs | 2 ++ src/config.rs | 2 +- src/config/sidebar.rs | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index ef33c693d1..577faf1ef2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -633,6 +633,7 @@ impl App { agent_view_override: None, sidebar_agents: config.ui.sidebar.agents.clone(), sidebar_spaces: config.ui.sidebar.spaces.clone(), + sidebar_wheel: config.ui.sidebar.wheel, next_agent_state_change_seq: 0, mouse_capture: config.ui.mouse_capture, copy_on_select: config.ui.copy_on_select, @@ -1503,6 +1504,7 @@ impl App { self.state.status_indicators = config.ui.status_indicators; self.state.sidebar_agents = config.ui.sidebar.agents.clone(); self.state.sidebar_spaces = config.ui.sidebar.spaces.clone(); + self.state.sidebar_wheel = config.ui.sidebar.wheel; self.state.agent_panel_scroll = 0; self.state.accent = crate::config::parse_color(&config.ui.accent); if !self.state.local_sound_playback && self.state.sound != config.ui.sound { diff --git a/src/app/state.rs b/src/app/state.rs index cb6873638f..445268c7db 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1422,6 +1422,7 @@ pub struct AppState { pub agent_view_override: Option, pub sidebar_agents: crate::config::AgentsSidebarConfig, pub sidebar_spaces: crate::config::SpacesSidebarConfig, + pub sidebar_wheel: crate::config::SidebarWheelConfig, pub next_agent_state_change_seq: u64, /// Capture mouse input for Herdr's own mouse UI. When false, Herdr only /// captures mouse while the focused pane app requests mouse reporting. @@ -1792,6 +1793,7 @@ impl AppState { agent_view_override: None, sidebar_agents: crate::config::AgentsSidebarConfig::default(), sidebar_spaces: crate::config::SpacesSidebarConfig::default(), + sidebar_wheel: crate::config::SidebarWheelConfig::default(), next_agent_state_change_seq: 0, mouse_capture: true, copy_on_select: true, diff --git a/src/config.rs b/src/config.rs index 7e7aecd215..221ff4ea5c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,7 +29,7 @@ pub use self::{ }, sidebar::{ AgentSidebarToken, AgentsSidebarConfig, SidebarConfig, SidebarTokenStyle, - SpaceSidebarToken, SpacesSidebarConfig, + SidebarWheelConfig, SpaceSidebarToken, SpacesSidebarConfig, }, sound::SoundConfig, tab_bar::TabBarRightEntryConfig, diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs index c548c839b2..17ab59e054 100644 --- a/src/config/sidebar.rs +++ b/src/config/sidebar.rs @@ -424,11 +424,30 @@ impl Default for SpacesSidebarConfig { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] +pub enum SidebarWheelConfig { + #[default] + #[serde(rename = "switch")] + Switch, + #[serde(rename = "scroll")] + Scroll, +} + +impl SidebarWheelConfig { + pub fn as_str(&self) -> &'static str { + match self { + Self::Switch => "switch", + Self::Scroll => "scroll", + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(default)] pub struct SidebarConfig { pub agents: AgentsSidebarConfig, pub spaces: SpacesSidebarConfig, + pub wheel: SidebarWheelConfig, } #[cfg(test)] @@ -645,4 +664,18 @@ rows = [[{ token = "git_status", fg = "#ff00aa" }], [{ token = "$jj", bold = tru ); } } + + #[test] + fn wheel_defaults_to_switch() { + let config = SidebarConfig::default(); + assert_eq!(config.wheel, SidebarWheelConfig::Switch); + } + + #[test] + fn wheel_parses_switch_and_scroll() { + let switch: SidebarConfig = toml::from_str("wheel = 'switch'").unwrap(); + assert_eq!(switch.wheel, SidebarWheelConfig::Switch); + let scroll: SidebarConfig = toml::from_str("wheel = 'scroll'").unwrap(); + assert_eq!(scroll.wheel, SidebarWheelConfig::Scroll); + } } From 028255469f0a5bcbd7280af97f027e62aab89d3a Mon Sep 17 00:00:00 2001 From: qianqian <1433687451@qq.com> Date: Fri, 14 Aug 2026 04:26:30 +0800 Subject: [PATCH 2/3] refactor(actions): expose cycle_agent_entry and workspace cycle helpers to crate --- src/app/actions.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/app/actions.rs b/src/app/actions.rs index d6e264f56f..b872ced934 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1339,8 +1339,7 @@ impl AppState { } } - #[cfg(test)] - pub fn next_workspace(&mut self) { + pub(crate) fn next_workspace(&mut self) { if self.workspaces.is_empty() { return; } @@ -1351,8 +1350,7 @@ impl AppState { self.switch_workspace(next); } - #[cfg(test)] - pub fn previous_workspace(&mut self) { + pub(crate) fn previous_workspace(&mut self) { if self.workspaces.is_empty() { return; } @@ -1513,8 +1511,7 @@ impl AppState { self.cycle_agent_entry(false); } - #[cfg(test)] - pub fn focus_agent_entry(&mut self, idx: usize) -> bool { + pub(crate) fn focus_agent_entry(&mut self, idx: usize) -> bool { let entries = crate::ui::agent_panel_entries(self); let Some(target) = entries.get(idx) else { return false; @@ -1535,8 +1532,7 @@ impl AppState { false } - #[cfg(test)] - fn cycle_agent_entry(&mut self, forward: bool) { + pub(crate) fn cycle_agent_entry(&mut self, forward: bool) { let entries = crate::ui::agent_panel_entries(self); if entries.is_empty() { return; From 41c2c5ef9b7ecc5c0e0d001eed6d8c147c68f23d Mon Sep 17 00:00:00 2001 From: qianqian <1433687451@qq.com> Date: Fri, 14 Aug 2026 04:38:49 +0800 Subject: [PATCH 3/3] feat(ui): wheel over sidebar switches agent or workspace (configurable) --- src/app/input/mouse.rs | 97 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 3066c2a4c2..1ef5f7afe3 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -988,7 +988,13 @@ impl AppState { 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 self.sidebar_wheel == crate::config::SidebarWheelConfig::Switch { + if over_agent_panel { + self.cycle_agent_entry(false); + } else { + self.previous_workspace(); + } + } else if over_agent_panel { if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( self, agent_area, )) { @@ -1007,7 +1013,13 @@ impl AppState { 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 self.sidebar_wheel == crate::config::SidebarWheelConfig::Switch { + if over_agent_panel { + self.cycle_agent_entry(true); + } else { + self.next_workspace(); + } + } else if over_agent_panel { if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics( self, agent_area, )) { @@ -4777,4 +4789,85 @@ mod tests { assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll); } + + #[test] + fn wheel_over_agent_panel_switches_agent() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("one"); + let root = ws.tabs[0].root_pane; + let second = ws.test_split(Direction::Horizontal); + ws.tabs[0].layout.focus_pane(root); + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.ensure_test_terminals(); + + // Agent panel entries only include panes whose terminal has an agent + // label; give both panes one so cycle_agent_entry has entries to cycle. + let tids: Vec<_> = app.state.workspaces[0].tabs[0] + .panes + .values() + .map(|pane| pane.attached_terminal_id.clone()) + .collect(); + for tid in tids { + app.state + .terminals + .get_mut(&tid) + .expect("test terminal present") + .set_agent_name("test-agent".into()); + } + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let agent_area = app.state.agent_panel_rect(); + assert_ne!(agent_area, Rect::default()); + + app.handle_mouse(mouse(MouseEventKind::ScrollDown, agent_area.x + 1, agent_area.y + 2)); + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(second)); + + app.handle_mouse(mouse(MouseEventKind::ScrollDown, agent_area.x + 1, agent_area.y + 2)); + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); + + app.handle_mouse(mouse(MouseEventKind::ScrollUp, agent_area.x + 1, agent_area.y + 2)); + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(second)); + } + + #[test] + fn wheel_over_workspace_list_switches_workspace() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let ws_area = app.state.workspace_list_rect(); + assert_ne!(ws_area, Rect::default()); + + app.handle_mouse(mouse(MouseEventKind::ScrollDown, ws_area.x + 1, ws_area.y + 2)); + assert_eq!(app.state.active, Some(1)); + + app.handle_mouse(mouse(MouseEventKind::ScrollDown, ws_area.x + 1, ws_area.y + 2)); + assert_eq!(app.state.active, Some(0)); // cycles back around + + app.handle_mouse(mouse(MouseEventKind::ScrollUp, ws_area.x + 1, ws_area.y + 2)); + assert_eq!(app.state.active, Some(1)); + } + + #[test] + fn wheel_scroll_mode_preserves_scrolling() { + let mut app = app_for_mouse_test(); + app.state.sidebar_wheel = crate::config::SidebarWheelConfig::Scroll; + app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let ws_area = app.state.workspace_list_rect(); + + // Scroll mode only moves the highlight; it never switches the active workspace. + app.handle_mouse(mouse(MouseEventKind::ScrollDown, ws_area.x + 1, ws_area.y + 2)); + assert_eq!(app.state.active, Some(0)); + } }