aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-14 02:50:00 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-14 02:50:00 +0300
commited4253d6031c9a3b12ddd6dfc7d7bf39480ae761 (patch)
tree315cea3ee7b5ad3a7c3f9fb31272064a0eed5dd2 /src
parent287bf51640f8e1bed227cb27d93377d962851196 (diff)
feat: add tmux-style mouse capture config
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs1
-rw-r--r--src/app/input/mouse.rs48
-rw-r--r--src/app/mod.rs29
-rw-r--r--src/app/runtime.rs6
-rw-r--r--src/app/state.rs18
-rw-r--r--src/client/mod.rs32
-rw-r--r--src/config/model.rs16
-rw-r--r--src/main.rs11
-rw-r--r--src/server/headless.rs42
-rw-r--r--src/server/protocol.rs15
-rw-r--r--src/ui.rs1
-rw-r--r--src/ui/sidebar.rs2
-rw-r--r--src/ui/tabs.rs35
13 files changed, 242 insertions, 14 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 6263679c..9477ca15 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -441,6 +441,7 @@ impl AppState {
area,
self.tab_scroll,
self.tab_scroll_follow_active,
+ self.mouse_capture,
);
self.tab_scroll = layout.scroll;
self.view.tab_hit_areas = layout.tab_hit_areas;
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index 779af3ed..9b4055e5 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -25,6 +25,28 @@ use super::{
};
impl AppState {
+ pub(crate) fn handle_pane_mouse_only(&mut self, mouse: MouseEvent) {
+ if self.mode != Mode::Terminal {
+ return;
+ }
+ let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else {
+ return;
+ };
+
+ match mouse.kind {
+ MouseEventKind::ScrollUp
+ | MouseEventKind::ScrollDown
+ | MouseEventKind::ScrollLeft
+ | MouseEventKind::ScrollRight => {
+ self.forward_pane_reported_wheel(&info, mouse);
+ }
+ MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => {
+ self.forward_pane_mouse_button(&info, mouse);
+ }
+ MouseEventKind::Moved => {}
+ }
+ }
+
pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> {
if self.mode == Mode::Onboarding {
self.handle_onboarding_mouse(mouse);
@@ -1118,6 +1140,32 @@ impl AppState {
true
}
+ fn forward_pane_reported_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
+ let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else {
+ return false;
+ };
+ let Some(rt) = ws.runtimes.get(&info.id) else {
+ return false;
+ };
+ if !rt
+ .input_state()
+ .is_some_and(crate::pane::InputState::mouse_reporting_enabled)
+ {
+ return false;
+ }
+ rt.scroll_reset();
+ let column = mouse.column.saturating_sub(info.inner_rect.x);
+ let row = mouse.row.saturating_sub(info.inner_rect.y);
+ let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers) else {
+ warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event");
+ return true;
+ };
+ if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
+ warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event");
+ }
+ true
+ }
+
pub(super) fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else {
return false;
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 8ef9d8f6..b56e8fc9 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -33,7 +33,10 @@ const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_secs(5);
const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350);
-use crossterm::terminal;
+use crossterm::{
+ event::{DisableMouseCapture, EnableMouseCapture},
+ execute, terminal,
+};
use ratatui::layout::Rect;
use ratatui::DefaultTerminal;
use tokio::sync::{mpsc, Notify};
@@ -364,6 +367,7 @@ impl App {
sidebar_collapsed: false,
sidebar_section_split,
agent_panel_scope,
+ mouse_capture: config.ui.mouse_capture,
confirm_close: config.ui.confirm_close,
show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders,
kitty_graphics_enabled: config.advanced.kitty_graphics,
@@ -449,6 +453,7 @@ impl App {
self.query_host_terminal_theme();
let mut needs_render = true;
+ let mut host_mouse_capture_active = self.state.mouse_capture;
while !self.state.should_quit {
if self.render_dirty.load(Ordering::Acquire) {
@@ -497,6 +502,7 @@ impl App {
let now = Instant::now();
self.sync_animation_timer(now);
+ self.sync_host_mouse_capture(&mut host_mouse_capture_active)?;
if needs_render && self.can_render_now(now) {
self.render_dirty.swap(false, Ordering::AcqRel);
@@ -577,6 +583,20 @@ impl App {
Ok(())
}
+ fn sync_host_mouse_capture(&self, active: &mut bool) -> io::Result<()> {
+ let desired = self.state.should_capture_host_mouse();
+ if desired == *active {
+ return Ok(());
+ }
+ if desired {
+ execute!(io::stdout(), EnableMouseCapture)?;
+ } else {
+ execute!(io::stdout(), DisableMouseCapture)?;
+ }
+ *active = desired;
+ Ok(())
+ }
+
pub(crate) fn dismiss_release_notes(&mut self) {
let preview = self
.state
@@ -683,6 +703,7 @@ impl App {
if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault {
self.state.sidebar_width = config.ui.sidebar_width;
}
+ self.state.mouse_capture = config.ui.mouse_capture;
self.state.confirm_close = config.ui.confirm_close;
self.state.show_agent_labels_on_pane_borders =
config.ui.show_agent_labels_on_pane_borders;
@@ -805,7 +826,11 @@ impl App {
}
}
crate::raw_input::RawInputEvent::Mouse(mouse) => {
- self.handle_mouse_event_headless(mouse);
+ if self.state.mouse_capture {
+ self.handle_mouse_event_headless(mouse);
+ } else {
+ self.state.handle_pane_mouse_only(mouse);
+ }
}
crate::raw_input::RawInputEvent::Paste(text) => {
if self.state.mode == Mode::Terminal {
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 3c67c306..6cdb6b43 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -87,7 +87,11 @@ impl App {
true
}
crate::raw_input::RawInputEvent::Mouse(mouse) => {
- self.handle_mouse(mouse);
+ if self.state.mouse_capture {
+ self.handle_mouse(mouse);
+ } else {
+ self.state.handle_pane_mouse_only(mouse);
+ }
true
}
crate::raw_input::RawInputEvent::OuterFocusGained => {
diff --git a/src/app/state.rs b/src/app/state.rs
index c79fbc24..155851d7 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -709,6 +709,9 @@ pub struct AppState {
/// Ratio of sidebar height allocated to the workspaces section.
pub sidebar_section_split: f32,
pub agent_panel_scope: AgentPanelScope,
+ /// Capture mouse input for Herdr's own mouse UI. When false, Herdr only
+ /// captures mouse while the focused pane app requests mouse reporting.
+ pub mouse_capture: bool,
pub confirm_close: bool,
pub show_agent_labels_on_pane_borders: bool,
pub kitty_graphics_enabled: bool,
@@ -752,6 +755,20 @@ impl AppState {
self.show_agent_labels_on_pane_borders
}
+ pub fn focused_pane_requests_mouse_capture(&self) -> bool {
+ self.mode == Mode::Terminal
+ && self
+ .active
+ .and_then(|idx| self.workspaces.get(idx))
+ .and_then(crate::workspace::Workspace::focused_runtime)
+ .and_then(crate::pane::PaneRuntime::input_state)
+ .is_some_and(crate::pane::InputState::mouse_reporting_enabled)
+ }
+
+ pub fn should_capture_host_mouse(&self) -> bool {
+ self.mouse_capture || self.focused_pane_requests_mouse_capture()
+ }
+
pub fn is_prefix(&self, key: &crossterm::event::KeyEvent) -> bool {
key_matches(key, self.prefix_code, self.prefix_mods)
}
@@ -877,6 +894,7 @@ impl AppState {
sidebar_collapsed: false,
sidebar_section_split: 0.5,
agent_panel_scope: AgentPanelScope::AllWorkspaces,
+ mouse_capture: true,
confirm_close: true,
show_agent_labels_on_pane_borders: false,
kitty_graphics_enabled: false,
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 9679e95c..075eeb55 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -46,6 +46,8 @@ static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::ne
struct ClientState {
/// Stateful semantic-frame encoder used when the server sends FrameData.
blit_encoder: blit::BlitEncoder,
+ /// Whether host mouse capture is currently active.
+ mouse_capture_active: bool,
/// The terminal size we reported to the server in our last Hello/Resize.
reported_size: (u16, u16),
/// Client-local sound playback config, refreshed on server request.
@@ -141,14 +143,18 @@ impl From<protocol::FramingError> for ClientError {
// Terminal setup / restore
// ---------------------------------------------------------------------------
-/// Sets up the terminal for client mode (raw mode, mouse, keyboard enhancements).
+/// Sets up the terminal for client mode (raw mode, optional mouse, keyboard enhancements).
///
/// Returns a guard that restores the terminal when dropped.
-fn setup_terminal() -> io::Result<TerminalGuard> {
+fn setup_terminal(mouse_capture: bool) -> io::Result<TerminalGuard> {
ratatui::init();
+ if mouse_capture {
+ execute!(io::stdout(), EnableMouseCapture)?;
+ } else {
+ execute!(io::stdout(), DisableMouseCapture)?;
+ }
execute!(
io::stdout(),
- EnableMouseCapture,
EnableBracketedPaste,
EnableFocusChange,
PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
@@ -176,6 +182,14 @@ fn write_terminal_restore_postlude(writer: &mut impl io::Write) -> io::Result<()
writer.flush()
}
+fn set_mouse_capture(enabled: bool) -> io::Result<()> {
+ if enabled {
+ execute!(io::stdout(), EnableMouseCapture)
+ } else {
+ execute!(io::stdout(), DisableMouseCapture)
+ }
+}
+
fn restore_terminal_state(in_tmux: bool) {
let _ = clear_received_kitty_graphics(&mut io::stdout());
@@ -337,7 +351,7 @@ pub fn run_client() -> io::Result<()> {
// Now set up the terminal (raw mode, mouse, keyboard enhancements).
// This must happen AFTER the handshake succeeds, so we don't leave
// the terminal in raw mode if the server rejects us.
- let _guard = setup_terminal().map_err(|err| {
+ let _guard = setup_terminal(false).map_err(|err| {
eprintln!("herdr: failed to set up terminal: {err}");
err
})?;
@@ -372,6 +386,7 @@ pub fn run_client() -> io::Result<()> {
should_quit,
sound_config,
kitty_graphics_enabled,
+ false,
negotiated_encoding,
)
.await
@@ -416,10 +431,12 @@ async fn run_client_loop(
should_quit: Arc<AtomicBool>,
sound_config: crate::config::SoundConfig,
kitty_graphics_enabled: bool,
+ mouse_capture_active: bool,
negotiated_encoding: RenderEncoding,
) -> Result<(), ClientError> {
let mut state = ClientState {
blit_encoder: blit::BlitEncoder::new(),
+ mouse_capture_active,
reported_size: (cols, rows),
sound_config,
kitty_graphics_enabled,
@@ -530,6 +547,13 @@ async fn run_client_loop(
ServerMessage::ReloadSoundConfig => {
reload_local_sound_config(&mut state.sound_config);
}
+ ServerMessage::MouseCapture { enabled } => {
+ let desired = enabled;
+ if desired != state.mouse_capture_active {
+ set_mouse_capture(desired).map_err(ClientError::ConnectionFailed)?;
+ state.mouse_capture_active = desired;
+ }
+ }
ServerMessage::Welcome { .. } => {
debug!("received unexpected Welcome in main loop");
}
diff --git a/src/config/model.rs b/src/config/model.rs
index ffc117ed..a576418b 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -129,6 +129,8 @@ pub struct KeysConfig {
#[serde(default)]
pub struct UiConfig {
pub sidebar_width: u16,
+ /// Capture mouse input for Herdr's mouse UI. Default: true.
+ pub mouse_capture: bool,
/// Ask for confirmation before closing a workspace. Default: true.
pub confirm_close: bool,
/// Show agent labels in split pane borders when no manual pane label is set. Default: false.
@@ -195,6 +197,7 @@ impl Default for UiConfig {
fn default() -> Self {
Self {
sidebar_width: 26,
+ mouse_capture: true,
confirm_close: true,
show_agent_labels_on_pane_borders: false,
agent_panel_scope: AgentPanelScopeConfig::All,
@@ -273,6 +276,19 @@ show_agent_labels_on_pane_borders = true
}
#[test]
+ fn mouse_capture_default_on_and_parse() {
+ let default_config = Config::default();
+ assert!(default_config.ui.mouse_capture);
+
+ let toml = r#"
+[ui]
+mouse_capture = false
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert!(!config.ui.mouse_capture);
+ }
+
+ #[test]
fn toast_config_parses() {
let toml = r#"
[ui.toast]
diff --git a/src/main.rs b/src/main.rs
index 7fcf624f..8a43746d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -120,6 +120,11 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Sidebar width (auto-scaled based on workspace names, this sets the default)
# sidebar_width = 26
+# Capture mouse input for Herdr's mouse UI.
+# Set false to let the terminal handle normal clicks, such as Cmd-clicking URLs.
+# Pane apps like lazygit and btop can still receive mouse when they request it.
+# mouse_capture = true
+
# Ask for confirmation before closing a workspace
# confirm_close = true
@@ -456,9 +461,13 @@ fn main() -> io::Result<()> {
let result = rt.block_on(async {
let mut terminal = ratatui::init();
+ if config.ui.mouse_capture {
+ execute!(io::stdout(), EnableMouseCapture)?;
+ } else {
+ execute!(io::stdout(), DisableMouseCapture)?;
+ }
execute!(
io::stdout(),
- EnableMouseCapture,
EnableBracketedPaste,
EnableFocusChange,
PushKeyboardEnhancementFlags(crate::input::ime_compatible_keyboard_enhancement_flags())
diff --git a/src/server/headless.rs b/src/server/headless.rs
index f490ec45..3ff3e587 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -205,6 +205,8 @@ struct ClientConnection {
graphics_cache: crate::kitty_graphics::HostGraphicsCache,
/// Whether a render was skipped because the render channel was full.
render_pending: bool,
+ /// Last host mouse capture mode sent to this client.
+ host_mouse_capture_active: Option<bool>,
/// Channels for sending framed ServerMessage data to the client writer thread.
writer: Option<ClientWriter>,
}
@@ -228,6 +230,7 @@ impl ClientConnection {
render_state: ClientRenderState::new(render_encoding),
graphics_cache: crate::kitty_graphics::HostGraphicsCache::default(),
render_pending: false,
+ host_mouse_capture_active: None,
writer,
}
}
@@ -416,6 +419,7 @@ impl HeadlessServer {
}
self.drain_client_sound_config_reload_request();
+ self.stream_host_mouse_capture_mode();
self.app.sync_headless_animation_timer(now);
@@ -1423,6 +1427,44 @@ impl HeadlessServer {
changed
}
+ fn stream_host_mouse_capture_mode(&mut self) {
+ let enabled = self.app.state.should_capture_host_mouse();
+ let serialized = match Self::frame_server_message(&ServerMessage::MouseCapture { enabled })
+ {
+ Ok(framed) => framed,
+ Err(err) => {
+ warn!(err = %err, "failed to serialize mouse capture mode for clients");
+ return;
+ }
+ };
+
+ let mut broken_clients: Vec<u64> = Vec::new();
+ for (&client_id, client) in &mut self.clients {
+ if client.host_mouse_capture_active == Some(enabled) {
+ continue;
+ }
+ let Some(writer) = &client.writer else {
+ continue;
+ };
+ if writer.control.send(serialized.clone()).is_err() {
+ debug!(
+ client_id,
+ "client writer channel closed during mouse capture update"
+ );
+ broken_clients.push(client_id);
+ continue;
+ }
+ client.host_mouse_capture_active = Some(enabled);
+ }
+
+ for client_id in broken_clients {
+ let foreground_changed = self.remove_client(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size();
+ }
+ }
+ }
+
/// Renders the current state to client-sized virtual buffers and streams
/// frames to all connected clients.
fn render_and_stream(&mut self) {
diff --git a/src/server/protocol.rs b/src/server/protocol.rs
index 8748c41c..e0a3c4a0 100644
--- a/src/server/protocol.rs
+++ b/src/server/protocol.rs
@@ -294,6 +294,12 @@ pub enum ServerMessage {
/// Client-local sound config changed on disk; refresh it without reconnecting.
ReloadSoundConfig,
+
+ /// Whether the client should currently capture host mouse input.
+ MouseCapture {
+ /// True when Herdr mouse UI is enabled or the focused pane app requests mouse reporting.
+ enabled: bool,
+ },
}
// ---------------------------------------------------------------------------
@@ -799,6 +805,15 @@ mod tests {
assert_eq!(msg, decoded);
}
+ #[test]
+ fn server_mouse_capture_roundtrip() {
+ let msg = ServerMessage::MouseCapture { enabled: true };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
// ---- Framing ----
#[test]
diff --git a/src/ui.rs b/src/ui.rs
index a27388db..fc888396 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -188,6 +188,7 @@ fn compute_view_internal(
tab_bar_rect,
app.tab_scroll,
app.tab_scroll_follow_active,
+ app.mouse_capture,
)
})
.unwrap_or_default();
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index 8a104dba..f10c4241 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -716,7 +716,7 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕");
}
- if list_bottom > area.y {
+ if app.mouse_capture && list_bottom > area.y {
let new_rect = app.sidebar_new_button_rect();
frame.render_widget(
Paragraph::new(Span::styled("new", Style::default().fg(p.overlay0))),
diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs
index a6ce0e68..ce859432 100644
--- a/src/ui/tabs.rs
+++ b/src/ui/tabs.rs
@@ -98,11 +98,28 @@ pub(crate) fn compute_tab_bar_view(
area: Rect,
current_scroll: usize,
follow_active: bool,
+ mouse_chrome: bool,
) -> TabBarView {
if area.width == 0 || area.height == 0 {
return TabBarView::default();
}
+ if !mouse_chrome {
+ let max_scroll = max_tab_scroll(ws, area);
+ let scroll = if follow_active {
+ centered_tab_scroll(ws, area).min(max_scroll)
+ } else {
+ current_scroll.min(max_scroll)
+ };
+ return TabBarView {
+ scroll,
+ tab_hit_areas: layout_tab_hit_areas(ws, area, scroll),
+ scroll_left_hit_area: Rect::default(),
+ scroll_right_hit_area: Rect::default(),
+ new_tab_hit_area: Rect::default(),
+ };
+ }
+
let area_right = area.x + area.width;
let all_tabs_area = Rect::new(
area.x,
@@ -253,7 +270,7 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
let can_scroll_right = app.view.tab_scroll_right_hit_area.width > 0
&& last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len());
- if app.view.tab_scroll_left_hit_area.width > 0 {
+ if app.mouse_capture && app.view.tab_scroll_left_hit_area.width > 0 {
let style = if can_scroll_left {
Style::default().fg(p.overlay1).bg(p.surface0)
} else {
@@ -268,7 +285,7 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
);
}
- if app.view.tab_scroll_right_hit_area.width > 0 {
+ if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 {
let style = if can_scroll_right {
Style::default().fg(p.overlay1).bg(p.surface0)
} else {
@@ -330,7 +347,7 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
}
}
- if app.view.new_tab_hit_area.width > 0 {
+ if app.mouse_capture && app.view.new_tab_hit_area.width > 0 {
frame.render_widget(
Paragraph::new(" + ").style(Style::default().fg(p.overlay1)),
app.view.new_tab_hit_area,
@@ -338,7 +355,11 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
}
if first_visible_idx.is_some_and(|idx| idx > 0) {
- let x = app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width;
+ let x = if app.mouse_capture && app.view.tab_scroll_left_hit_area.width > 0 {
+ app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width
+ } else {
+ area.x
+ };
if x < area.x + area.width {
frame.buffer_mut()[(x, area.y)]
.set_symbol("…")
@@ -346,7 +367,11 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
}
}
if last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()) {
- let x = app.view.tab_scroll_right_hit_area.x.saturating_sub(1);
+ let x = if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 {
+ app.view.tab_scroll_right_hit_area.x.saturating_sub(1)
+ } else {
+ area.x + area.width.saturating_sub(1)
+ };
if x >= area.x && x < area.x + area.width {
frame.buffer_mut()[(x, area.y)]
.set_symbol("…")