aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-21 16:30:04 +0300
committerGitHub <noreply@github.com>2026-04-21 16:30:04 +0300
commit3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch)
treec457a7cf0af271dea1a412474fad5a33c1b60be8 /src/app/input
parent2818ff492d45d17fd4f9b15478d703c6c664a8c0 (diff)
feat: add persistent server-client sessions
Make herdr persistent by default. Launching herdr now starts or reattaches to a background session server. Clients can detach and reattach while panes and agent processes keep running. Session mode now supports multi-client attach, auto-detect startup, and a thin-client/headless-server split. This also refactors the large app, ui, input, pane, config, workspace, and persistence modules into smaller focused submodules, while preserving behavior and colocating tests with the code they exercise. Upgrade notes: - persistence mode is now the default - in-app quit detaches the current client instead of stopping the server - use `herdr server stop` to stop the background session - use `--no-session` for the old single-process behavior - default socket paths now live under the config directory
Diffstat (limited to 'src/app/input')
-rw-r--r--src/app/input/mod.rs321
-rw-r--r--src/app/input/modal.rs739
-rw-r--r--src/app/input/mouse.rs1258
-rw-r--r--src/app/input/navigate.rs780
-rw-r--r--src/app/input/overlays.rs483
-rw-r--r--src/app/input/selection.rs68
-rw-r--r--src/app/input/settings.rs352
-rw-r--r--src/app/input/sidebar.rs1219
-rw-r--r--src/app/input/terminal.rs440
9 files changed, 5660 insertions, 0 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
new file mode 100644
index 00000000..2f069fea
--- /dev/null
+++ b/src/app/input/mod.rs
@@ -0,0 +1,321 @@
+//! Input handling — translates crossterm key/mouse events into state mutations.
+
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+
+use crate::input::TerminalKey;
+use ratatui::layout::Direction;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ScrollbarClickTarget {
+ Thumb { grab_row_offset: u16 },
+ Track { offset_from_bottom: usize },
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[cfg(test)]
+enum WheelRouting {
+ HostScroll,
+ MouseReport,
+ AlternateScroll,
+}
+
+const WORKSPACE_DRAG_THRESHOLD: u16 = 1;
+const TAB_DRAG_THRESHOLD: u16 = 1;
+
+mod modal;
+mod mouse;
+mod navigate;
+mod overlays;
+mod selection;
+mod settings;
+mod sidebar;
+mod terminal;
+
+pub(crate) use self::{
+ modal::{
+ handle_confirm_close_key, handle_context_menu_key, handle_global_menu_key,
+ handle_keybind_help_key, handle_rename_key, handle_resize_key,
+ },
+ navigate::{handle_navigate_key, terminal_direct_navigation_action},
+};
+use self::{
+ modal::{
+ modal_action_from_key, ModalAction, ONBOARDING_NOTIFICATION_ACTIONS,
+ ONBOARDING_WELCOME_ACTIONS, RELEASE_NOTES_ACTIONS,
+ },
+ settings::SettingsAction,
+};
+use super::state::{AppState, Mode};
+use super::App;
+
+// ---------------------------------------------------------------------------
+// Key handling
+// ---------------------------------------------------------------------------
+
+impl App {
+ pub(super) async fn handle_key(&mut self, key: TerminalKey) {
+ match self.state.mode {
+ Mode::Terminal => self.handle_terminal_key(key).await,
+ _ => {
+ let key = key.as_key_event();
+ match self.state.mode {
+ Mode::Onboarding => self.handle_onboarding_key(key),
+ Mode::ReleaseNotes => self.handle_release_notes_key(key),
+ Mode::Navigate => self.handle_navigate_key(key),
+ Mode::RenameWorkspace | Mode::RenameTab => {
+ handle_rename_key(&mut self.state, key)
+ }
+ Mode::Resize => handle_resize_key(&mut self.state, key),
+ Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key),
+ Mode::ContextMenu => handle_context_menu_key(&mut self.state, key),
+ Mode::Settings => self.handle_settings_key(key),
+ Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key),
+ Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key),
+ Mode::Terminal => unreachable!(),
+ }
+ }
+ }
+ }
+
+ pub(super) async fn handle_paste(&mut self, text: String) {
+ if self.state.mode != Mode::Terminal {
+ return;
+ }
+ if let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) {
+ if let Some(rt) = ws.focused_runtime() {
+ let _ = rt.send_paste(text).await;
+ }
+ }
+ }
+
+ pub(crate) fn handle_onboarding_key(&mut self, key: KeyEvent) {
+ match self.state.onboarding_step {
+ 0 => match key.code {
+ KeyCode::Right | KeyCode::Char('l') => {
+ self.state.onboarding_step = 1;
+ }
+ _ => match modal_action_from_key(&key, ONBOARDING_WELCOME_ACTIONS) {
+ Some(ModalAction::Continue) => self.state.onboarding_step = 1,
+ _ => {}
+ },
+ },
+ _ => match key.code {
+ KeyCode::Up | KeyCode::Char('k') => self.state.onboarding_list.move_prev(),
+ KeyCode::Down | KeyCode::Char('j') => self.state.onboarding_list.move_next(4),
+ KeyCode::Left | KeyCode::Char('h') => {
+ self.state.onboarding_step = 0;
+ }
+ KeyCode::Char(c) if ('1'..='4').contains(&c) => {
+ self.state
+ .onboarding_list
+ .select((c as usize) - ('1' as usize));
+ }
+ _ => match modal_action_from_key(&key, ONBOARDING_NOTIFICATION_ACTIONS) {
+ Some(ModalAction::Back) => self.state.onboarding_step = 0,
+ Some(ModalAction::Save) => self.complete_onboarding(),
+ _ => {}
+ },
+ },
+ }
+ }
+
+ pub(crate) fn handle_release_notes_key(&mut self, key: KeyEvent) {
+ match key.code {
+ KeyCode::Up | KeyCode::Char('k') => self.scroll_release_notes(-1),
+ KeyCode::Down | KeyCode::Char('j') => self.scroll_release_notes(1),
+ KeyCode::PageUp => self.scroll_release_notes(-8),
+ KeyCode::PageDown => self.scroll_release_notes(8),
+ KeyCode::Home => {
+ if let Some(notes) = &mut self.state.release_notes {
+ notes.scroll = 0;
+ }
+ }
+ KeyCode::End => {
+ let max_scroll = self.state.release_notes_max_scroll();
+ if let Some(notes) = &mut self.state.release_notes {
+ notes.scroll = max_scroll;
+ }
+ }
+ _ => match modal_action_from_key(&key, RELEASE_NOTES_ACTIONS) {
+ Some(ModalAction::Close) => self.dismiss_release_notes(),
+ _ => {}
+ },
+ }
+ }
+
+ pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) {
+ if self.handle_overlay_mouse(mouse) {
+ return;
+ }
+
+ if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
+ && self.state.on_sidebar_divider(mouse.column, mouse.row)
+ {
+ let now = std::time::Instant::now();
+ let is_double_click = self
+ .last_sidebar_divider_click
+ .is_some_and(|last| now.duration_since(last) <= super::SIDEBAR_DOUBLE_CLICK_WINDOW);
+ self.last_sidebar_divider_click = Some(now);
+
+ if is_double_click {
+ self.state.sidebar_width = self.state.default_sidebar_width;
+ self.state.sidebar_width_auto = false;
+ self.state.mark_session_dirty();
+ self.state.drag = None;
+ return;
+ }
+ }
+
+ if let Some(action) = self.state.handle_mouse(mouse) {
+ match action {
+ SettingsAction::SaveTheme(name) => self.save_theme(&name),
+ SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
+ SettingsAction::SaveToast(enabled) => self.save_toast(enabled),
+ }
+ }
+
+ if let Some(content) = self.state.request_clipboard_write.take() {
+ if self
+ .event_tx
+ .try_send(crate::events::AppEvent::ClipboardWrite { content })
+ .is_err()
+ {
+ tracing::warn!("failed to queue clipboard write event");
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Mouse handling
+// ---------------------------------------------------------------------------
+
+// Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App
+impl AppState {
+ pub(crate) fn split_pane(&mut self, direction: Direction) {
+ // Actual PTY spawning happens in Workspace::split_focused
+ // which needs events channel — this is called from navigate_key
+ // where we don't have async context, so the workspace handles it
+ let (rows, cols) = self.estimate_pane_size();
+ let new_rows = (rows / 2).max(4);
+ let new_cols = (cols / 2).max(10);
+
+ let cwd = self
+ .active
+ .and_then(|i| self.workspaces.get(i))
+ .and_then(|ws| ws.focused_runtime())
+ .and_then(|rt| rt.cwd());
+
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ if let Ok(new_id) = ws.split_focused(
+ direction,
+ new_rows,
+ new_cols,
+ cwd,
+ self.pane_scrollback_limit_bytes,
+ self.host_terminal_theme,
+ ) {
+ ws.layout.focus_pane(new_id);
+ self.mark_session_dirty();
+ self.mode = Mode::Terminal;
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+fn state_with_workspaces(names: &[&str]) -> AppState {
+ let mut state = AppState::test_new();
+ state.workspaces = names
+ .iter()
+ .map(|name| crate::workspace::Workspace::test_new(name))
+ .collect();
+ if !state.workspaces.is_empty() {
+ state.active = Some(0);
+ state.selected = 0;
+ state.mode = Mode::Navigate;
+ }
+ state
+}
+
+#[cfg(test)]
+fn app_for_mouse_test() -> App {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.mode = Mode::Terminal;
+ app.state.update_available = None;
+ app.state.latest_release_notes_available = false;
+ app.state.view.sidebar_rect = ratatui::layout::Rect::new(0, 0, 26, 20);
+ app.state.view.terminal_area = ratatui::layout::Rect::new(26, 0, 80, 20);
+ app
+}
+
+#[cfg(test)]
+fn mouse(
+ kind: crossterm::event::MouseEventKind,
+ col: u16,
+ row: u16,
+) -> crossterm::event::MouseEvent {
+ crossterm::event::MouseEvent {
+ kind,
+ column: col,
+ row,
+ modifiers: crossterm::event::KeyModifiers::empty(),
+ }
+}
+
+#[cfg(test)]
+fn numbered_lines_bytes(count: usize) -> Vec<u8> {
+ (0..count)
+ .map(|i| format!("{i:06}\r\n"))
+ .collect::<String>()
+ .into_bytes()
+}
+
+#[cfg(test)]
+fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot {
+ crate::persist::capture(
+ &state.workspaces,
+ state.active,
+ state.selected,
+ state.agent_panel_scope,
+ state.sidebar_width,
+ state.sidebar_section_split,
+ )
+}
+
+#[cfg(test)]
+fn root_layout_ratio(snapshot: &crate::persist::SessionSnapshot) -> Option<f32> {
+ match &snapshot.workspaces.first()?.tabs.first()?.layout {
+ crate::persist::LayoutSnapshot::Split { ratio, .. } => Some(*ratio),
+ crate::persist::LayoutSnapshot::Pane(_) => None,
+ }
+}
+
+#[cfg(test)]
+fn unique_temp_path(name: &str) -> std::path::PathBuf {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
+}
+
+#[cfg(test)]
+fn wait_for_file(path: &std::path::Path) -> String {
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
+ while std::time::Instant::now() < deadline {
+ if let Ok(content) = std::fs::read_to_string(path) {
+ return content;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(20));
+ }
+ panic!("timed out waiting for {}", path.display());
+}
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
new file mode 100644
index 00000000..2ba9b6f4
--- /dev/null
+++ b/src/app/input/modal.rs
@@ -0,0 +1,739 @@
+use crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::{Direction, Rect};
+
+use crate::{
+ app::state::{key_matches, AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode},
+ layout::NavDirection,
+};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum ModalAction {
+ Continue,
+ Back,
+ Save,
+ Clear,
+ Cancel,
+ Confirm,
+ Apply,
+ Close,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum ModalKeyBinding {
+ Enter,
+ Esc,
+ CtrlC,
+}
+
+impl ModalKeyBinding {
+ fn matches(self, key: &KeyEvent) -> bool {
+ match self {
+ Self::Enter => key.code == KeyCode::Enter,
+ Self::Esc => key.code == KeyCode::Esc,
+ Self::CtrlC => {
+ key.code == KeyCode::Char('c')
+ && key.modifiers == crossterm::event::KeyModifiers::CONTROL
+ }
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub(super) struct ModalActionSpec<A> {
+ pub action: A,
+ pub bindings: &'static [ModalKeyBinding],
+}
+
+pub(super) fn modal_action_from_key<A: Copy>(
+ key: &KeyEvent,
+ specs: &[ModalActionSpec<A>],
+) -> Option<A> {
+ specs
+ .iter()
+ .find(|spec| spec.bindings.iter().any(|binding| binding.matches(key)))
+ .map(|spec| spec.action)
+}
+
+pub(super) fn modal_action_from_buttons<A: Copy>(
+ col: u16,
+ row: u16,
+ buttons: &[(Rect, A)],
+) -> Option<A> {
+ buttons.iter().find_map(|(rect, action)| {
+ (col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height)
+ .then_some(*action)
+ })
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum GlobalMenuAction {
+ Quit,
+ WhatsNew,
+ Keybinds,
+ ReloadKeybinds,
+ Settings,
+}
+
+pub(super) fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> {
+ let mut actions = vec![
+ GlobalMenuAction::Settings,
+ GlobalMenuAction::Keybinds,
+ GlobalMenuAction::ReloadKeybinds,
+ ];
+ if state.update_available.is_some() || state.latest_release_notes_available {
+ actions.push(GlobalMenuAction::WhatsNew);
+ }
+ actions.push(GlobalMenuAction::Quit);
+ actions
+}
+
+pub(super) fn open_global_menu(state: &mut AppState) {
+ state.global_menu = MenuListState::new(0);
+ state.mode = Mode::GlobalMenu;
+}
+
+pub(super) fn open_keybind_help(state: &mut AppState) {
+ state.keybind_help.scroll = 0;
+ state.mode = Mode::KeybindHelp;
+}
+
+fn open_update_release_notes(state: &mut AppState) {
+ let Some(notes) = crate::release_notes::load_latest() else {
+ return;
+ };
+
+ state.release_notes = Some(crate::app::state::ReleaseNotesState {
+ version: notes.version,
+ body: notes.body,
+ scroll: 0,
+ preview: notes.preview,
+ });
+ state.mode = Mode::ReleaseNotes;
+}
+
+pub(super) fn request_quit_or_detach(state: &mut AppState) {
+ if state.quit_detaches {
+ state.detach_requested = true;
+ } else {
+ state.should_quit = true;
+ }
+}
+
+pub(super) fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) {
+ match action {
+ GlobalMenuAction::Quit => request_quit_or_detach(state),
+ GlobalMenuAction::WhatsNew => open_update_release_notes(state),
+ GlobalMenuAction::Keybinds => open_keybind_help(state),
+ GlobalMenuAction::ReloadKeybinds => {
+ state.request_reload_keybinds = true;
+ leave_modal(state);
+ }
+ GlobalMenuAction::Settings => super::settings::open_settings(state),
+ }
+}
+
+pub(crate) fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) {
+ let actions = global_menu_actions(state);
+ match key.code {
+ KeyCode::Esc => leave_modal(state),
+ KeyCode::Up | KeyCode::Char('k') => state.global_menu.move_prev(),
+ KeyCode::Down | KeyCode::Char('j') => state.global_menu.move_next(actions.len()),
+ KeyCode::Enter => {
+ if let Some(action) = actions.get(state.global_menu.highlighted).copied() {
+ apply_global_menu_action(state, action);
+ }
+ }
+ _ => {}
+ }
+}
+
+pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) {
+ match key.code {
+ KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1),
+ KeyCode::Down | KeyCode::Char('j') => state.scroll_keybind_help(1),
+ KeyCode::PageUp => state.scroll_keybind_help(-8),
+ KeyCode::PageDown => state.scroll_keybind_help(8),
+ KeyCode::Home => state.keybind_help.scroll = 0,
+ KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(),
+ KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => leave_modal(state),
+ _ => {}
+ }
+}
+
+pub(super) fn open_rename_workspace(state: &mut AppState, ws_idx: usize) {
+ state.selected = ws_idx;
+ state.name_input = state.workspaces[ws_idx].display_name();
+ state.name_input_replace_on_type = false;
+ state.mode = Mode::RenameWorkspace;
+}
+
+pub(super) fn open_rename_active_tab(state: &mut AppState, replace_on_type: bool) {
+ state.creating_new_tab = false;
+ state.requested_new_tab_name = None;
+ if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) {
+ if let Some(name) = ws.active_tab_display_name() {
+ state.name_input = name;
+ state.name_input_replace_on_type = replace_on_type;
+ state.mode = Mode::RenameTab;
+ }
+ }
+}
+
+fn next_new_tab_default_name(state: &AppState) -> String {
+ state
+ .active
+ .and_then(|i| state.workspaces.get(i))
+ .map(|ws| (ws.tabs.len() + 1).to_string())
+ .unwrap_or_else(|| "1".to_string())
+}
+
+pub(super) fn open_new_tab_dialog(state: &mut AppState) {
+ state.creating_new_tab = true;
+ state.requested_new_tab_name = None;
+ state.name_input = next_new_tab_default_name(state);
+ state.name_input_replace_on_type = true;
+ state.mode = Mode::RenameTab;
+}
+
+pub(super) fn leave_modal(state: &mut AppState) {
+ if state.active.is_some() {
+ state.mode = Mode::Terminal;
+ } else {
+ state.mode = Mode::Navigate;
+ }
+}
+
+pub(super) const ONBOARDING_WELCOME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec {
+ action: ModalAction::Continue,
+ bindings: &[ModalKeyBinding::Enter],
+}];
+
+pub(super) const ONBOARDING_NOTIFICATION_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
+ ModalActionSpec {
+ action: ModalAction::Back,
+ bindings: &[ModalKeyBinding::Esc],
+ },
+ ModalActionSpec {
+ action: ModalAction::Save,
+ bindings: &[ModalKeyBinding::Enter],
+ },
+];
+
+pub(super) const RELEASE_NOTES_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec {
+ action: ModalAction::Close,
+ bindings: &[ModalKeyBinding::Enter, ModalKeyBinding::Esc],
+}];
+
+pub(super) const RENAME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
+ ModalActionSpec {
+ action: ModalAction::Save,
+ bindings: &[ModalKeyBinding::Enter],
+ },
+ ModalActionSpec {
+ action: ModalAction::Clear,
+ bindings: &[ModalKeyBinding::CtrlC],
+ },
+ ModalActionSpec {
+ action: ModalAction::Cancel,
+ bindings: &[ModalKeyBinding::Esc],
+ },
+];
+
+pub(super) const CONFIRM_CLOSE_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
+ ModalActionSpec {
+ action: ModalAction::Confirm,
+ bindings: &[ModalKeyBinding::Enter],
+ },
+ ModalActionSpec {
+ action: ModalAction::Cancel,
+ bindings: &[ModalKeyBinding::Esc],
+ },
+];
+
+pub(super) const SETTINGS_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
+ ModalActionSpec {
+ action: ModalAction::Apply,
+ bindings: &[ModalKeyBinding::Enter],
+ },
+ ModalActionSpec {
+ action: ModalAction::Close,
+ bindings: &[ModalKeyBinding::Esc],
+ },
+];
+
+pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) {
+ match action {
+ ModalAction::Save => {
+ let new_name = if state.name_input.trim().is_empty() {
+ state.name_input.clone()
+ } else {
+ state.name_input.trim().to_string()
+ };
+ match state.mode {
+ Mode::RenameWorkspace if !state.workspaces.is_empty() => {
+ if !new_name.is_empty() {
+ state.workspaces[state.selected].set_custom_name(new_name);
+ state.mark_session_dirty();
+ }
+ }
+ Mode::RenameTab if state.creating_new_tab => {
+ state.request_new_tab = true;
+ let default_name = next_new_tab_default_name(state);
+ state.requested_new_tab_name =
+ if new_name.is_empty() || new_name == default_name {
+ None
+ } else {
+ Some(new_name)
+ };
+ }
+ Mode::RenameTab => {
+ if let Some(ws) = state.active.and_then(|i| state.workspaces.get_mut(i)) {
+ if let Some(tab) = ws.active_tab_mut() {
+ let keep_auto_name =
+ tab.is_auto_named() && new_name == tab.number.to_string();
+ if !new_name.is_empty() && !keep_auto_name {
+ tab.set_custom_name(new_name);
+ state.mark_session_dirty();
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ state.creating_new_tab = false;
+ state.name_input.clear();
+ state.name_input_replace_on_type = false;
+ leave_modal(state);
+ }
+ ModalAction::Clear => {
+ state.name_input.clear();
+ state.name_input_replace_on_type = false;
+ }
+ ModalAction::Cancel => {
+ state.creating_new_tab = false;
+ state.requested_new_tab_name = None;
+ state.name_input.clear();
+ state.name_input_replace_on_type = false;
+ leave_modal(state);
+ }
+ _ => {}
+ }
+}
+
+pub(crate) fn handle_rename_key(state: &mut AppState, key: KeyEvent) {
+ if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) {
+ apply_rename_action(state, action);
+ return;
+ }
+
+ match key.code {
+ KeyCode::Backspace => {
+ if state.name_input_replace_on_type {
+ state.name_input.clear();
+ state.name_input_replace_on_type = false;
+ } else {
+ state.name_input.pop();
+ }
+ }
+ KeyCode::Char(c) => {
+ if state.name_input_replace_on_type {
+ state.name_input.clear();
+ state.name_input_replace_on_type = false;
+ }
+ state.name_input.push(c);
+ }
+ _ => {}
+ }
+}
+
+pub(crate) fn handle_resize_key(state: &mut AppState, key: KeyEvent) {
+ if key.code == KeyCode::Esc
+ || key.code == KeyCode::Enter
+ || key_matches(
+ &key,
+ state.keybinds.resize_mode.0,
+ state.keybinds.resize_mode.1,
+ )
+ {
+ if state.active.is_some() {
+ state.mode = Mode::Terminal;
+ } else {
+ state.mode = Mode::Navigate;
+ }
+ return;
+ }
+
+ match key.code {
+ KeyCode::Char('h') | KeyCode::Left => state.resize_pane(NavDirection::Left),
+ KeyCode::Char('l') | KeyCode::Right => state.resize_pane(NavDirection::Right),
+ KeyCode::Char('j') | KeyCode::Down => state.resize_pane(NavDirection::Down),
+ KeyCode::Char('k') | KeyCode::Up => state.resize_pane(NavDirection::Up),
+ _ => {}
+ }
+}
+
+pub(super) fn open_confirm_close(state: &mut AppState) {
+ state.mode = Mode::ConfirmClose;
+}
+
+pub(super) fn confirm_close_accept(state: &mut AppState) {
+ state.close_selected_workspace();
+ if state.workspaces.is_empty() {
+ state.mode = Mode::Navigate;
+ } else {
+ state.mode = Mode::Terminal;
+ }
+}
+
+pub(super) fn confirm_close_cancel(state: &mut AppState) {
+ state.mode = Mode::Navigate;
+}
+
+pub(crate) fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) {
+ match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) {
+ Some(ModalAction::Confirm) => confirm_close_accept(state),
+ Some(ModalAction::Cancel) => confirm_close_cancel(state),
+ _ => {}
+ }
+}
+
+pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: usize) {
+ let item = menu.items().get(idx).copied();
+ match (menu.kind, item) {
+ (ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => {
+ open_rename_workspace(state, ws_idx);
+ }
+ (ContextMenuKind::Workspace { ws_idx }, Some("Close")) => {
+ state.selected = ws_idx;
+ if state.confirm_close {
+ open_confirm_close(state);
+ } else {
+ state.close_selected_workspace();
+ state.mode = Mode::Navigate;
+ }
+ }
+ (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => {
+ state.selected = ws_idx;
+ state.active = Some(ws_idx);
+ state.switch_tab(tab_idx);
+ open_new_tab_dialog(state);
+ }
+ (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => {
+ state.selected = ws_idx;
+ state.active = Some(ws_idx);
+ state.switch_tab(tab_idx);
+ open_rename_active_tab(state, false);
+ }
+ (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => {
+ state.selected = ws_idx;
+ state.active = Some(ws_idx);
+ state.switch_tab(tab_idx);
+ state.close_tab();
+ state.mode = if state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+ }
+ (ContextMenuKind::Pane, Some("Split vertical")) => {
+ state.split_pane(Direction::Horizontal);
+ state.mode = Mode::Terminal;
+ }
+ (ContextMenuKind::Pane, Some("Split horizontal")) => {
+ state.split_pane(Direction::Vertical);
+ state.mode = Mode::Terminal;
+ }
+ (ContextMenuKind::Pane, Some("Fullscreen")) => {
+ state.toggle_fullscreen();
+ state.mode = Mode::Terminal;
+ }
+ (ContextMenuKind::Pane, Some("Close pane")) => {
+ state.close_pane();
+ state.mode = if state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+ }
+ _ => leave_modal(state),
+ }
+}
+
+pub(crate) fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) {
+ match key.code {
+ KeyCode::Esc => {
+ state.context_menu = None;
+ leave_modal(state);
+ }
+ KeyCode::Up => {
+ if let Some(menu) = &mut state.context_menu {
+ menu.list.move_prev();
+ }
+ }
+ KeyCode::Down => {
+ if let Some(menu) = &mut state.context_menu {
+ menu.list.move_next(menu.items().len());
+ }
+ }
+ KeyCode::Enter => {
+ if let Some(menu) = state.context_menu.take() {
+ let idx = menu.list.highlighted;
+ apply_context_menu_action(state, menu, idx);
+ }
+ }
+ _ => {}
+ }
+}
+
+impl AppState {
+ pub(super) fn global_menu_item_at(&self, col: u16, row: u16) -> Option<GlobalMenuAction> {
+ let rect = self.global_menu_rect();
+ if col <= rect.x
+ || col >= rect.x + rect.width.saturating_sub(1)
+ || row <= rect.y
+ || row >= rect.y + rect.height.saturating_sub(1)
+ {
+ return None;
+ }
+ let idx = (row - rect.y - 1) as usize;
+ global_menu_actions(self).get(idx).copied()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+ use ratatui::layout::Rect;
+
+ use super::super::{capture_snapshot, state_with_workspaces};
+ use super::*;
+
+ #[test]
+ fn custom_resize_key_exits_resize_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::Resize;
+ state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.resize_mode_label = "g".into();
+
+ handle_resize_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn rename_modal_keyboard_and_mouse_share_actions() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::RenameWorkspace;
+ state.name_input = "hello".into();
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
+ );
+ assert!(state.name_input.is_empty());
+
+ state.name_input = "renamed".into();
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+ assert_eq!(state.mode, Mode::Terminal);
+ assert_eq!(state.workspaces[0].display_name(), "renamed");
+ let snapshot = capture_snapshot(&state);
+ assert_eq!(
+ snapshot.workspaces[0].custom_name.as_deref(),
+ Some("renamed")
+ );
+
+ state.view.sidebar_rect = Rect::new(0, 0, 26, 20);
+ state.view.terminal_area = Rect::new(26, 0, 80, 20);
+ state.mode = Mode::RenameWorkspace;
+ state.name_input = "mouse".into();
+ let inner = state.rename_modal_inner().unwrap();
+ let (save, _, _) = crate::ui::rename_button_rects(inner);
+ let action = modal_action_from_buttons(save.x, save.y, &[(save, ModalAction::Save)]);
+ assert_eq!(action, Some(ModalAction::Save));
+ }
+
+ #[test]
+ fn tab_rename_updates_captured_snapshot() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::RenameTab;
+ state.name_input = "logs".into();
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ let snapshot = capture_snapshot(&state);
+ assert_eq!(
+ snapshot.workspaces[0].tabs[0].custom_name.as_deref(),
+ Some("logs")
+ );
+ }
+
+ #[test]
+ fn rename_cancel_returns_to_terminal_when_workspace_is_active() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::RenameTab;
+ state.name_input = "test".into();
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ assert!(state.name_input.is_empty());
+ }
+
+ #[test]
+ fn rename_modal_replaces_prefilled_text_on_first_type() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::RenameTab;
+ state.name_input = "2".into();
+ state.name_input_replace_on_type = true;
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty()),
+ );
+ assert_eq!(state.name_input, "n");
+ assert!(!state.name_input_replace_on_type);
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()),
+ );
+ assert_eq!(state.name_input, "ne");
+ }
+
+ #[test]
+ fn open_rename_active_tab_can_prefill_default_new_tab_name() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.workspaces[0].test_add_tab(None);
+ state.workspaces[0].switch_tab(1);
+
+ open_rename_active_tab(&mut state, true);
+
+ assert_eq!(state.mode, Mode::RenameTab);
+ assert_eq!(state.name_input, "2");
+ assert!(state.name_input_replace_on_type);
+ }
+
+ #[test]
+ fn cancel_new_tab_dialog_leaves_workspace_unchanged() {
+ let mut state = state_with_workspaces(&["test"]);
+ open_new_tab_dialog(&mut state);
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ assert!(!state.creating_new_tab);
+ assert!(!state.request_new_tab);
+ assert!(state.requested_new_tab_name.is_none());
+ assert_eq!(state.workspaces[0].tabs.len(), 1);
+ }
+
+ #[test]
+ fn saving_new_tab_dialog_requests_creation_with_name() {
+ let mut state = state_with_workspaces(&["test"]);
+ open_new_tab_dialog(&mut state);
+ state.name_input = "logs".into();
+ state.name_input_replace_on_type = false;
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ assert!(!state.creating_new_tab);
+ assert!(state.request_new_tab);
+ assert_eq!(state.requested_new_tab_name.as_deref(), Some("logs"));
+ }
+
+ #[test]
+ fn saving_new_tab_dialog_with_default_name_keeps_tab_auto_named() {
+ let mut state = state_with_workspaces(&["test"]);
+ open_new_tab_dialog(&mut state);
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ assert!(!state.creating_new_tab);
+ assert!(state.request_new_tab);
+ assert!(state.requested_new_tab_name.is_none());
+ }
+
+ #[test]
+ fn closing_first_auto_tab_resets_remaining_auto_tab_and_next_prompt() {
+ let mut state = state_with_workspaces(&["test"]);
+ open_new_tab_dialog(&mut state);
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ state.workspaces[0].test_add_tab(state.requested_new_tab_name.as_deref());
+ state.request_new_tab = false;
+ state.requested_new_tab_name = None;
+
+ state.workspaces[0].close_tab(0);
+ state.workspaces[0].switch_tab(0);
+
+ assert_eq!(state.workspaces[0].tabs[0].display_name(), "1");
+ assert!(state.workspaces[0].tabs[0].custom_name.is_none());
+
+ open_new_tab_dialog(&mut state);
+ assert_eq!(state.name_input, "2");
+ }
+
+ #[test]
+ fn renaming_auto_tab_to_its_default_number_keeps_it_auto_named() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.workspaces[0].test_add_tab(None);
+ state.workspaces[0].switch_tab(1);
+
+ open_rename_active_tab(&mut state, false);
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ assert!(state.workspaces[0].tabs[1].custom_name.is_none());
+ assert_eq!(state.workspaces[0].tabs[1].display_name(), "2");
+ }
+
+ #[test]
+ fn confirm_close_keyboard_actions_are_direct_not_focused() {
+ let mut state = state_with_workspaces(&["a", "b"]);
+ state.mode = Mode::ConfirmClose;
+ state.selected = 1;
+
+ handle_confirm_close_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
+ );
+ assert_eq!(state.mode, Mode::Navigate);
+ assert_eq!(state.workspaces.len(), 2);
+
+ state.mode = Mode::ConfirmClose;
+ handle_confirm_close_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+ assert_eq!(state.workspaces.len(), 1);
+ }
+}
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
new file mode 100644
index 00000000..0349d236
--- /dev/null
+++ b/src/app/input/mouse.rs
@@ -0,0 +1,1258 @@
+use bytes::Bytes;
+use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::{Direction, Rect};
+use tracing::warn;
+
+use crate::{
+ app::state::{
+ AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget,
+ MenuListState, Mode, TabPressState, WorkspacePressState,
+ },
+ layout::{PaneInfo, SplitBorder},
+ selection::Selection,
+};
+
+#[cfg(test)]
+use super::WheelRouting;
+use super::{
+ modal::{
+ apply_context_menu_action, apply_global_menu_action, apply_rename_action,
+ confirm_close_accept, confirm_close_cancel, global_menu_actions, leave_modal,
+ modal_action_from_buttons, open_global_menu, open_new_tab_dialog, ModalAction,
+ },
+ settings::SettingsAction,
+ ScrollbarClickTarget, TAB_DRAG_THRESHOLD, WORKSPACE_DRAG_THRESHOLD,
+};
+
+impl AppState {
+ pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> {
+ if self.mode == Mode::Onboarding {
+ self.handle_onboarding_mouse(mouse);
+ return None;
+ }
+
+ if self.mode == Mode::Settings {
+ return self.handle_settings_mouse(mouse);
+ }
+
+ let launcher_enabled = !self.sidebar_collapsed
+ && matches!(
+ self.mode,
+ Mode::Terminal
+ | Mode::Navigate
+ | Mode::Resize
+ | Mode::GlobalMenu
+ | Mode::KeybindHelp
+ );
+ let launcher = self.global_launcher_rect();
+ let launcher_hit = launcher_enabled
+ && mouse.column >= launcher.x
+ && mouse.column < launcher.x + launcher.width
+ && mouse.row >= launcher.y
+ && mouse.row < launcher.y + launcher.height;
+
+ if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu {
+ let actions = global_menu_actions(self);
+ let hovered = self
+ .global_menu_item_at(mouse.column, mouse.row)
+ .and_then(|action| actions.iter().position(|item| *item == action));
+ self.global_menu.hover(hovered);
+ return None;
+ }
+
+ if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && launcher_hit {
+ if self.mode == Mode::GlobalMenu {
+ leave_modal(self);
+ } else {
+ open_global_menu(self);
+ }
+ return None;
+ }
+
+ if self.mode == Mode::GlobalMenu {
+ if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
+ if let Some(action) = self.global_menu_item_at(mouse.column, mouse.row) {
+ apply_global_menu_action(self, action);
+ } else {
+ leave_modal(self);
+ }
+ }
+ return None;
+ }
+
+ if self.mode == Mode::KeybindHelp {
+ return None;
+ }
+
+ let sidebar = self.view.sidebar_rect;
+ let in_sidebar = mouse.column >= sidebar.x
+ && mouse.column < sidebar.x + sidebar.width
+ && mouse.row >= sidebar.y
+ && mouse.row < sidebar.y + sidebar.height;
+
+ match mouse.kind {
+ MouseEventKind::Down(MouseButton::Left) => {
+ self.selection = None;
+ self.workspace_press = None;
+
+ if self.mode == Mode::ConfirmClose {
+ let popup = self.confirm_close_rect();
+ let inner = Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ );
+ let (confirm, cancel) = crate::ui::confirm_close_button_rects(inner);
+ match modal_action_from_buttons(
+ mouse.column,
+ mouse.row,
+ &[
+ (confirm, ModalAction::Confirm),
+ (cancel, ModalAction::Cancel),
+ ],
+ ) {
+ Some(ModalAction::Confirm) => confirm_close_accept(self),
+ Some(ModalAction::Cancel) | None => confirm_close_cancel(self),
+ _ => {}
+ }
+ return None;
+ }
+
+ if matches!(self.mode, Mode::RenameWorkspace | Mode::RenameTab) {
+ let action = self
+ .rename_modal_inner()
+ .map(crate::ui::rename_button_rects)
+ .and_then(|(save, clear, cancel)| {
+ modal_action_from_buttons(
+ mouse.column,
+ mouse.row,
+ &[
+ (save, ModalAction::Save),
+ (clear, ModalAction::Clear),
+ (cancel, ModalAction::Cancel),
+ ],
+ )
+ })
+ .unwrap_or(ModalAction::Cancel);
+ apply_rename_action(self, action);
+ return None;
+ }
+
+ if self.mode == Mode::ContextMenu {
+ let item_idx = self.context_menu_item_at(mouse.column, mouse.row);
+ if let Some(menu) = self.context_menu.take() {
+ if let Some(idx) = item_idx {
+ apply_context_menu_action(self, menu, idx);
+ } else {
+ leave_modal(self);
+ }
+ }
+ return None;
+ }
+
+ if self.on_sidebar_divider(mouse.column, mouse.row) {
+ self.drag = Some(DragState {
+ target: DragTarget::SidebarDivider,
+ });
+ self.set_manual_sidebar_width(mouse.column);
+ 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 {
+ target: DragTarget::PaneSplit {
+ path: border.path.clone(),
+ direction: border.direction,
+ area: border.area,
+ },
+ });
+ return None;
+ }
+
+ if let Some((pane_id, target)) =
+ self.scrollbar_target_at(mouse.column, mouse.row)
+ {
+ self.focus_pane(pane_id);
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.drag = Some(DragState {
+ target: DragTarget::PaneScrollbar {
+ pane_id,
+ grab_row_offset,
+ },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => {
+ self.set_pane_scroll_offset(pane_id, offset_from_bottom);
+ }
+ }
+ if self.mode != Mode::Terminal {
+ self.mode = Mode::Terminal;
+ }
+ return None;
+ }
+ }
+
+ if self.on_tab_scroll_left_button(mouse.column, mouse.row) {
+ self.scroll_tabs_left();
+ return None;
+ }
+ if self.on_tab_scroll_right_button(mouse.column, mouse.row) {
+ self.scroll_tabs_right();
+ return None;
+ }
+ if let (Some(ws_idx), Some(tab_idx)) =
+ (self.active, self.tab_at(mouse.column, mouse.row))
+ {
+ self.tab_press = Some(TabPressState {
+ ws_idx,
+ tab_idx,
+ start_col: mouse.column,
+ start_row: mouse.row,
+ });
+ return None;
+ }
+ if self.on_new_tab_button(mouse.column, mouse.row) {
+ open_new_tab_dialog(self);
+ return None;
+ }
+
+ if in_sidebar {
+ if self.sidebar_collapsed {
+ if self.on_collapsed_sidebar_toggle(mouse.column, mouse.row) {
+ self.sidebar_collapsed = false;
+ return None;
+ }
+
+ if let Some(idx) = self.collapsed_workspace_at_row(mouse.row) {
+ self.switch_workspace(idx);
+ self.mode = Mode::Terminal;
+ return None;
+ }
+
+ if let Some((ws_idx, tab_idx, pane_id)) =
+ self.collapsed_agent_detail_target_at(mouse.row)
+ {
+ self.switch_workspace(ws_idx);
+ self.switch_tab(tab_idx);
+ self.focus_pane(pane_id);
+ self.mode = Mode::Terminal;
+ }
+ return None;
+ }
+
+ let new_button = self.sidebar_new_button_rect();
+ let on_new_button = mouse.row >= new_button.y
+ && mouse.row < new_button.y + new_button.height
+ && mouse.column >= new_button.x
+ && mouse.column < new_button.x + new_button.width;
+ if on_new_button {
+ self.request_new_workspace = true;
+ return None;
+ }
+
+ if let Some(target) =
+ self.workspace_list_scrollbar_target_at(mouse.column, mouse.row)
+ {
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.drag = Some(DragState {
+ target: DragTarget::WorkspaceListScrollbar { grab_row_offset },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => {
+ self.set_workspace_list_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ return None;
+ }
+
+ if let Some(idx) = self.workspace_at_row(mouse.row) {
+ self.workspace_press = Some(WorkspacePressState {
+ ws_idx: idx,
+ start_col: mouse.column,
+ start_row: mouse.row,
+ });
+ return None;
+ }
+
+ if self.on_agent_panel_scope_toggle(mouse.column, mouse.row) {
+ self.agent_panel_scope = match self.agent_panel_scope {
+ AgentPanelScope::CurrentWorkspace => AgentPanelScope::AllWorkspaces,
+ AgentPanelScope::AllWorkspaces => AgentPanelScope::CurrentWorkspace,
+ };
+ self.agent_panel_scroll = 0;
+ self.mark_session_dirty();
+ 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;
+ }
+
+ if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row)
+ {
+ self.switch_workspace(ws_idx);
+ self.switch_tab(tab_idx);
+ self.focus_pane(pane_id);
+ self.mode = Mode::Terminal;
+ return None;
+ }
+ } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
+ self.focus_pane(info.id);
+ if self.mode != Mode::Terminal {
+ self.mode = Mode::Terminal;
+ }
+
+ if self.forward_pane_mouse_button(&info, mouse) {
+ self.selection = None;
+ return None;
+ }
+
+ let (row, col) = (
+ mouse.row - info.inner_rect.y,
+ mouse.column - info.inner_rect.x,
+ );
+ self.selection = Some(Selection::anchor(
+ info.id,
+ row,
+ col,
+ self.pane_scroll_metrics(info.id),
+ ));
+ } else if let Some(info) = self.view.pane_infos.iter().find(|p| {
+ mouse.column >= p.rect.x
+ && mouse.column < p.rect.x + p.rect.width
+ && mouse.row >= p.rect.y
+ && mouse.row < p.rect.y + p.rect.height
+ }) {
+ let id = info.id;
+ self.focus_pane(id);
+ if self.mode != Mode::Terminal {
+ self.mode = Mode::Terminal;
+ }
+ }
+ }
+
+ MouseEventKind::Drag(MouseButton::Left) => {
+ if self.selection.is_some() {
+ self.update_selection_drag(mouse.column, mouse.row);
+ return None;
+ }
+
+ if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
+ if self.forward_pane_mouse_button(&info, mouse) {
+ self.selection = None;
+ return None;
+ }
+ }
+
+ let workspace_drop_index = self.workspace_drop_index_at_row(mouse.row);
+ let tab_drop_index = self.tab_drop_index_at(mouse.column, mouse.row);
+ if self.drag.is_none() {
+ if let Some(press) = &self.workspace_press {
+ let delta_col = mouse.column.abs_diff(press.start_col);
+ let delta_row = mouse.row.abs_diff(press.start_row);
+ if delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD {
+ self.drag = Some(DragState {
+ target: DragTarget::WorkspaceReorder {
+ source_ws_idx: press.ws_idx,
+ insert_idx: workspace_drop_index,
+ },
+ });
+ }
+ } else if let Some(press) = &self.tab_press {
+ let delta_col = mouse.column.abs_diff(press.start_col);
+ let delta_row = mouse.row.abs_diff(press.start_row);
+ if delta_col.max(delta_row) >= TAB_DRAG_THRESHOLD {
+ self.drag = Some(DragState {
+ target: DragTarget::TabReorder {
+ ws_idx: press.ws_idx,
+ source_tab_idx: press.tab_idx,
+ insert_idx: tab_drop_index,
+ },
+ });
+ }
+ }
+ }
+
+ if let Some(DragState {
+ target: DragTarget::WorkspaceReorder { insert_idx, .. },
+ }) = &mut self.drag
+ {
+ *insert_idx = workspace_drop_index;
+ } else if let Some(DragState {
+ target:
+ DragTarget::TabReorder {
+ ws_idx, insert_idx, ..
+ },
+ }) = &mut self.drag
+ {
+ if self.active == Some(*ws_idx) {
+ *insert_idx = tab_drop_index;
+ }
+ } else if let Some(drag) = &self.drag {
+ match &drag.target {
+ DragTarget::WorkspaceReorder { .. } | DragTarget::TabReorder { .. } => {}
+ DragTarget::WorkspaceListScrollbar { grab_row_offset } => {
+ if let Some(offset_from_bottom) =
+ self.workspace_list_offset_for_drag_row(mouse.row, *grab_row_offset)
+ {
+ 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,
+ area,
+ } => {
+ let ratio = match direction {
+ Direction::Horizontal => {
+ (mouse.column.saturating_sub(area.x)) as f32
+ / area.width.max(1) as f32
+ }
+ Direction::Vertical => {
+ (mouse.row.saturating_sub(area.y)) as f32
+ / area.height.max(1) as f32
+ }
+ };
+ let ratio = ratio.clamp(0.1, 0.9);
+ let path = path.clone();
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ ws.layout.set_ratio_at(&path, ratio);
+ self.mark_session_dirty();
+ }
+ }
+ DragTarget::PaneScrollbar {
+ pane_id,
+ grab_row_offset,
+ } => {
+ if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row(
+ *pane_id,
+ mouse.row,
+ *grab_row_offset,
+ ) {
+ self.set_pane_scroll_offset(*pane_id, offset_from_bottom);
+ }
+ }
+ DragTarget::SidebarDivider => {
+ self.set_manual_sidebar_width(mouse.column);
+ }
+ DragTarget::SidebarSectionDivider => {
+ self.set_sidebar_section_split(mouse.row);
+ }
+ DragTarget::ReleaseNotesScrollbar { .. }
+ | DragTarget::KeybindHelpScrollbar { .. } => {}
+ }
+ }
+ }
+
+ MouseEventKind::Up(MouseButton::Left) => {
+ if self.selection.is_some() {
+ self.workspace_press = None;
+ self.tab_press = None;
+ self.drag = None;
+ let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click());
+ if was_click {
+ self.selection = None;
+ } else {
+ self.copy_selection();
+ }
+ return None;
+ }
+
+ if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
+ if self.forward_pane_mouse_button(&info, mouse) {
+ self.selection = None;
+ self.workspace_press = None;
+ self.tab_press = None;
+ self.drag = None;
+ return None;
+ }
+ }
+
+ let workspace_press = self.workspace_press.take();
+ let tab_press = self.tab_press.take();
+ match self.drag.take() {
+ Some(DragState {
+ target:
+ DragTarget::WorkspaceReorder {
+ source_ws_idx,
+ insert_idx: Some(insert_idx),
+ },
+ }) => {
+ self.move_workspace(source_ws_idx, insert_idx);
+ }
+ Some(DragState {
+ target:
+ DragTarget::TabReorder {
+ ws_idx,
+ source_tab_idx,
+ insert_idx: Some(insert_idx),
+ },
+ }) => {
+ if self.active == Some(ws_idx) {
+ self.move_tab(source_tab_idx, insert_idx);
+ self.mode = Mode::Terminal;
+ }
+ }
+ Some(_) => {}
+ None => {
+ if let Some(press) = workspace_press {
+ self.switch_workspace(press.ws_idx);
+ self.mode = Mode::Terminal;
+ return None;
+ }
+ if let Some(press) = tab_press {
+ if self.active == Some(press.ws_idx) {
+ self.switch_tab(press.tab_idx);
+ self.mode = Mode::Terminal;
+ return None;
+ }
+ }
+ let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click());
+ if was_click {
+ self.selection = None;
+ } else {
+ self.copy_selection();
+ }
+ }
+ }
+ }
+
+ MouseEventKind::Up(MouseButton::Middle) | MouseEventKind::Drag(MouseButton::Middle)
+ if !in_sidebar =>
+ {
+ if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
+ let _ = self.forward_pane_mouse_button(&info, mouse);
+ }
+ }
+
+ MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => {
+ if self.on_tab_bar(mouse.column, mouse.row) {
+ match mouse.kind {
+ MouseEventKind::ScrollUp => self.scroll_tabs_left(),
+ MouseEventKind::ScrollDown => self.scroll_tabs_right(),
+ _ => {}
+ }
+ } else if !self.scroll_selection_with_wheel(mouse) {
+ self.selection = None;
+ self.handle_terminal_wheel(mouse);
+ }
+ }
+
+ MouseEventKind::ScrollUp if in_sidebar => {
+ 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;
+ self.ensure_workspace_visible(self.selected);
+ }
+ }
+ MouseEventKind::ScrollDown if in_sidebar => {
+ 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;
+ self.ensure_workspace_visible(self.selected);
+ }
+ }
+
+ MouseEventKind::Moved if self.mode == Mode::ContextMenu => {
+ let hovered = self.context_menu_item_at(mouse.column, mouse.row);
+ if let Some(menu) = &mut self.context_menu {
+ menu.list.hover(hovered);
+ }
+ }
+
+ MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => {
+ if self
+ .workspace_list_scrollbar_target_at(mouse.column, mouse.row)
+ .is_some()
+ {
+ return None;
+ }
+ if let Some(idx) = self.workspace_at_row(mouse.row) {
+ self.selected = idx;
+ self.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Workspace { ws_idx: idx },
+ x: mouse.column,
+ y: mouse.row,
+ list: MenuListState::new(0),
+ });
+ self.mode = Mode::ContextMenu;
+ }
+ }
+
+ MouseEventKind::Down(MouseButton::Right)
+ if self.tab_at(mouse.column, mouse.row).is_some() =>
+ {
+ if let (Some(ws_idx), Some(tab_idx)) =
+ (self.active, self.tab_at(mouse.column, mouse.row))
+ {
+ self.switch_tab(tab_idx);
+ self.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Tab { ws_idx, tab_idx },
+ x: mouse.column,
+ y: mouse.row,
+ list: MenuListState::new(0),
+ });
+ self.mode = Mode::ContextMenu;
+ }
+ }
+
+ MouseEventKind::Down(MouseButton::Right) if !in_sidebar => {
+ if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
+ self.focus_pane(info.id);
+ self.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Pane,
+ x: mouse.column,
+ y: mouse.row,
+ list: MenuListState::new(0),
+ });
+ self.mode = Mode::ContextMenu;
+ }
+ }
+
+ _ => {}
+ }
+
+ None
+ }
+
+ pub(super) fn screen_rect(&self) -> Rect {
+ let sidebar = self.view.sidebar_rect;
+ let terminal = self.view.terminal_area;
+ let x = sidebar.x.min(terminal.x);
+ let y = sidebar.y.min(terminal.y);
+ let right = (sidebar.x + sidebar.width).max(terminal.x + terminal.width);
+ let bottom = (sidebar.y + sidebar.height).max(terminal.y + terminal.height);
+ Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y))
+ }
+
+ pub(crate) fn context_menu_rect(&self) -> Option<Rect> {
+ let menu = self.context_menu.as_ref()?;
+ let screen = self.screen_rect();
+ let max_item_w = menu
+ .items()
+ .iter()
+ .map(|item| item.len() as u16)
+ .max()
+ .unwrap_or(0);
+ let menu_w = (max_item_w + 4).max(14).min(screen.width.max(1));
+ let menu_h = (menu.items().len() as u16 + 2).min(screen.height.max(1));
+ let x = menu.x.min(screen.x + screen.width.saturating_sub(menu_w));
+ let y = menu.y.min(screen.y + screen.height.saturating_sub(menu_h));
+ Some(Rect::new(x, y, menu_w, menu_h))
+ }
+
+ pub(crate) fn confirm_close_rect(&self) -> Rect {
+ crate::ui::confirm_close_popup_rect(self.view.terminal_area).unwrap_or_default()
+ }
+
+ fn context_menu_item_at(&self, col: u16, row: u16) -> Option<usize> {
+ let menu_rect = self.context_menu_rect()?;
+ let inner_x = menu_rect.x + 1;
+ let inner_y = menu_rect.y + 1;
+ let inner_w = menu_rect.width.saturating_sub(2);
+ let inner_h = menu_rect.height.saturating_sub(2);
+ let item_count = self
+ .context_menu
+ .as_ref()
+ .map(|menu| menu.items().len() as u16)
+ .unwrap_or(0);
+ if col >= inner_x
+ && col < inner_x + inner_w
+ && row >= inner_y
+ && row < inner_y + inner_h.min(item_count)
+ {
+ Some((row - inner_y) as usize)
+ } else {
+ None
+ }
+ }
+
+ pub(super) fn tab_at(&self, col: u16, row: u16) -> Option<usize> {
+ self.view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .find_map(|(idx, area)| {
+ (area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width)
+ .then_some(idx)
+ })
+ }
+
+ pub(super) fn on_tab_bar(&self, col: u16, row: u16) -> bool {
+ let area = self.view.tab_bar_rect;
+ area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width
+ }
+
+ pub(super) fn on_tab_scroll_left_button(&self, col: u16, row: u16) -> bool {
+ let area = self.view.tab_scroll_left_hit_area;
+ area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width
+ }
+
+ pub(super) fn on_tab_scroll_right_button(&self, col: u16, row: u16) -> bool {
+ let area = self.view.tab_scroll_right_hit_area;
+ area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width
+ }
+
+ pub(super) fn tab_drop_index_at(&self, col: u16, row: u16) -> Option<usize> {
+ if !self.on_tab_bar(col, row) {
+ return None;
+ }
+
+ let visible_tabs: Vec<_> = self
+ .view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .filter(|(_, rect)| rect.width > 0)
+ .collect();
+ let (first_idx, first_rect) = *visible_tabs.first()?;
+ let (last_idx, last_rect) = *visible_tabs.last()?;
+
+ if self.on_tab_scroll_left_button(col, row) {
+ return Some(0);
+ }
+ if self.on_tab_scroll_right_button(col, row) {
+ return self
+ .active
+ .and_then(|idx| self.workspaces.get(idx))
+ .map(|ws| ws.tabs.len());
+ }
+
+ let left_edge = if first_idx == 0 {
+ first_rect.x
+ } else {
+ self.view.tab_scroll_left_hit_area.x + self.view.tab_scroll_left_hit_area.width
+ };
+ let right_edge = if self
+ .active
+ .and_then(|idx| self.workspaces.get(idx))
+ .is_some_and(|ws| last_idx + 1 >= ws.tabs.len())
+ {
+ last_rect.x + last_rect.width
+ } else {
+ self.view.tab_scroll_right_hit_area.x.saturating_sub(1)
+ };
+
+ if col <= left_edge {
+ return Some(first_idx);
+ }
+ if col >= right_edge {
+ return Some(last_idx + 1);
+ }
+
+ for (idx, rect) in visible_tabs {
+ let midpoint = rect.x + rect.width / 2;
+ if col < midpoint {
+ return Some(idx);
+ }
+ if col < rect.x + rect.width {
+ return Some(idx + 1);
+ }
+ }
+
+ Some(last_idx + 1)
+ }
+
+ pub(super) fn on_new_tab_button(&self, col: u16, row: u16) -> bool {
+ let area = self.view.new_tab_hit_area;
+ area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width
+ }
+
+ pub(super) fn find_border_at(&self, col: u16, row: u16) -> Option<&SplitBorder> {
+ self.view.split_borders.iter().find(|b| match b.direction {
+ Direction::Horizontal => {
+ (col as i32 - b.pos as i32).unsigned_abs() <= 1
+ && row >= b.area.y
+ && row < b.area.y + b.area.height
+ }
+ Direction::Vertical => {
+ (row as i32 - b.pos as i32).unsigned_abs() <= 1
+ && col >= b.area.x
+ && col < b.area.x + b.area.width
+ }
+ })
+ }
+
+ pub(super) fn pane_at(&self, col: u16, row: u16) -> Option<&PaneInfo> {
+ self.view.pane_infos.iter().find(|p| {
+ col >= p.inner_rect.x
+ && col < p.inner_rect.x + p.inner_rect.width
+ && row >= p.inner_rect.y
+ && row < p.inner_rect.y + p.inner_rect.height
+ })
+ }
+
+ pub(super) fn pane_mouse_target(&self, col: u16, row: u16) -> Option<&PaneInfo> {
+ self.pane_at(col, row)
+ .or_else(|| self.pane_frame_at(col, row))
+ }
+
+ pub(super) fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> {
+ self.view.pane_infos.iter().find(|info| info.id == pane_id)
+ }
+
+ pub(super) fn pane_frame_at(&self, col: u16, row: u16) -> Option<&PaneInfo> {
+ self.view.pane_infos.iter().find(|p| {
+ col >= p.rect.x
+ && col < p.rect.x + p.rect.width
+ && row >= p.rect.y
+ && row < p.rect.y + p.rect.height
+ })
+ }
+
+ pub(super) fn focus_pane(&mut self, pane_id: crate::layout::PaneId) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ if ws.layout.focused() != pane_id {
+ ws.layout.focus_pane(pane_id);
+ self.mark_session_dirty();
+ }
+ }
+ }
+
+ pub(super) fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
+ if let Some(rt) = ws.runtimes.get(&pane_id) {
+ rt.scroll_up(lines);
+ }
+ }
+ }
+
+ pub(super) fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
+ if let Some(rt) = ws.runtimes.get(&pane_id) {
+ rt.scroll_down(lines);
+ }
+ }
+ }
+
+ pub(super) fn pane_scroll_metrics(
+ &self,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<crate::pane::ScrollMetrics> {
+ self.active
+ .and_then(|i| self.workspaces.get(i))
+ .and_then(|ws| ws.runtime(pane_id))
+ .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ }
+
+ pub(super) fn handle_terminal_wheel(&mut self, mouse: MouseEvent) {
+ const LINES_PER_NOTCH: usize = 3;
+
+ if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
+ self.focus_pane(info.id);
+ if self.forward_pane_wheel(&info, mouse) {
+ return;
+ }
+ match mouse.kind {
+ MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH),
+ MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH),
+ _ => {}
+ }
+ return;
+ }
+
+ if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() {
+ self.focus_pane(info.id);
+ match mouse.kind {
+ MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH),
+ MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH),
+ _ => {}
+ }
+ return;
+ }
+
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
+ if let Some(rt) = ws.focused_runtime() {
+ match mouse.kind {
+ MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH),
+ MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH),
+ _ => {}
+ }
+ }
+ }
+ }
+
+ pub(super) fn forward_pane_mouse_button(&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;
+ };
+ 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_button(mouse.kind, column, row, mouse.modifiers) else {
+ return false;
+ };
+ rt.scroll_reset();
+ if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
+ warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse button 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;
+ };
+ let Some(rt) = ws.runtimes.get(&info.id) else {
+ return false;
+ };
+ match rt.wheel_routing() {
+ Some(crate::pane::WheelRouting::HostScroll) | None => false,
+ Some(crate::pane::WheelRouting::MouseReport) => {
+ 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
+ }
+ Some(crate::pane::WheelRouting::AlternateScroll) => {
+ rt.scroll_reset();
+ let Some(bytes) = rt.encode_alternate_scroll(mouse.kind) else {
+ return true;
+ };
+ if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
+ warn!(pane = info.id.raw(), err = %err, "failed to forward alternate-scroll key");
+ }
+ true
+ }
+ }
+ }
+
+ pub(super) fn set_pane_scroll_offset(
+ &self,
+ pane_id: crate::layout::PaneId,
+ offset_from_bottom: usize,
+ ) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
+ if let Some(rt) = ws.runtimes.get(&pane_id) {
+ rt.set_scroll_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ }
+
+ pub(super) fn scrollbar_target_at(
+ &self,
+ col: u16,
+ row: u16,
+ ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> {
+ let ws = self.active.and_then(|i| self.workspaces.get(i))?;
+ let info = self.view.pane_infos.iter().find(|info| {
+ crate::ui::pane_scrollbar_rect(info).is_some_and(|track| {
+ col >= track.x
+ && col < track.x + track.width
+ && row >= track.y
+ && row < track.y + track.height
+ })
+ })?;
+ let rt = ws.runtimes.get(&info.id)?;
+ let metrics = rt.scroll_metrics()?;
+ if metrics.max_offset_from_bottom == 0 {
+ return None;
+ }
+ let track = crate::ui::pane_scrollbar_rect(info)?;
+ if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
+ Some((info.id, ScrollbarClickTarget::Thumb { grab_row_offset }))
+ } else {
+ Some((
+ info.id,
+ ScrollbarClickTarget::Track {
+ offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
+ },
+ ))
+ }
+ }
+
+ pub(super) fn scrollbar_offset_for_pane_row(
+ &self,
+ pane_id: crate::layout::PaneId,
+ row: u16,
+ grab_row_offset: u16,
+ ) -> Option<usize> {
+ let ws = self.active.and_then(|i| self.workspaces.get(i))?;
+ let info = self
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == pane_id)?;
+ let track = crate::ui::pane_scrollbar_rect(info)?;
+ let rt = ws.runtimes.get(&pane_id)?;
+ let metrics = rt.scroll_metrics()?;
+ if metrics.max_offset_from_bottom == 0 {
+ return None;
+ }
+ Some(crate::ui::scrollbar_offset_from_drag_row(
+ metrics,
+ track,
+ row,
+ grab_row_offset,
+ ))
+ }
+}
+
+#[cfg(test)]
+pub(super) fn wheel_routing(input_state: crate::pane::InputState) -> WheelRouting {
+ if input_state.mouse_protocol_mode.reporting_enabled() {
+ WheelRouting::MouseReport
+ } else if input_state.alternate_screen && input_state.mouse_alternate_scroll {
+ WheelRouting::AlternateScroll
+ } else {
+ WheelRouting::HostScroll
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
+ use ratatui::layout::{Direction, Rect};
+
+ use super::super::{
+ app_for_mouse_test, capture_snapshot, handle_context_menu_key, mouse, root_layout_ratio,
+ };
+ use super::*;
+ use crate::{
+ app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode},
+ workspace::Workspace,
+ };
+
+ #[test]
+ fn hovering_context_menu_updates_highlight() {
+ let mut app = app_for_mouse_test();
+ app.state.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Workspace { ws_idx: 0 },
+ x: 2,
+ y: 2,
+ list: MenuListState::new(0),
+ });
+ app.state.mode = Mode::ContextMenu;
+
+ let menu = app.state.context_menu_rect().unwrap();
+ app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2));
+
+ assert_eq!(app.state.context_menu.unwrap().list.highlighted, 1);
+ }
+
+ #[test]
+ fn clicking_confirm_close_accepts_workspace_close() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
+ app.state.active = Some(0);
+ app.state.selected = 1;
+ app.state.mode = Mode::ConfirmClose;
+
+ let popup = app.state.confirm_close_rect();
+ let inner = Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ );
+ let (confirm, _) = crate::ui::confirm_close_button_rects(inner);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ confirm.x,
+ confirm.y,
+ ));
+
+ assert_eq!(app.state.workspaces.len(), 1);
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn clicking_confirm_close_accepts_after_workspace_context_menu_close() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.state.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Workspace { ws_idx: 1 },
+ x: 2,
+ y: 2,
+ list: MenuListState::new(1),
+ });
+ app.state.mode = Mode::ContextMenu;
+ handle_context_menu_key(
+ &mut app.state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+ assert_eq!(app.state.mode, Mode::ConfirmClose);
+ assert_eq!(app.state.selected, 1);
+
+ let popup = app.state.confirm_close_rect();
+ let inner = Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ );
+ let (confirm, _) = crate::ui::confirm_close_button_rects(inner);
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ confirm.x + 1,
+ confirm.y,
+ ));
+
+ assert_eq!(app.state.workspaces.len(), 1);
+ assert_eq!(app.state.workspaces[0].display_name(), "a");
+ }
+
+ #[test]
+ fn dragging_pane_split_updates_captured_layout_ratio() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.workspaces[0].test_split(Direction::Horizontal);
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let border = app.state.view.split_borders[0].clone();
+ let before = capture_snapshot(&app.state);
+ let drag_row = border.area.y.saturating_add(1);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ border.pos,
+ drag_row,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ border.pos.saturating_add(6),
+ drag_row,
+ ));
+
+ let after = capture_snapshot(&app.state);
+ assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after));
+ }
+
+ #[test]
+ fn wheel_routing_prefers_mouse_reporting() {
+ let input_state = crate::pane::InputState {
+ alternate_screen: true,
+ application_cursor: false,
+ bracketed_paste: false,
+ focus_reporting: false,
+ mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion,
+ mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr,
+ mouse_alternate_scroll: true,
+ };
+
+ assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport);
+ }
+
+ #[test]
+ fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() {
+ let input_state = crate::pane::InputState {
+ alternate_screen: true,
+ application_cursor: false,
+ bracketed_paste: false,
+ focus_reporting: false,
+ mouse_protocol_mode: crate::input::MouseProtocolMode::None,
+ mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default,
+ mouse_alternate_scroll: true,
+ };
+
+ assert_eq!(wheel_routing(input_state), WheelRouting::AlternateScroll);
+ }
+
+ #[test]
+ fn wheel_routing_falls_back_to_host_scrollback() {
+ let input_state = crate::pane::InputState {
+ alternate_screen: false,
+ application_cursor: false,
+ bracketed_paste: false,
+ focus_reporting: false,
+ mouse_protocol_mode: crate::input::MouseProtocolMode::None,
+ mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default,
+ mouse_alternate_scroll: true,
+ };
+
+ assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll);
+ }
+}
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
new file mode 100644
index 00000000..26769dfe
--- /dev/null
+++ b/src/app/input/navigate.rs
@@ -0,0 +1,780 @@
+use std::process::{Command, Stdio};
+
+use crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::Direction;
+
+use crate::{
+ app::{
+ state::{key_matches, AppState, Mode},
+ App,
+ },
+ layout::NavDirection,
+};
+
+pub(crate) fn terminal_direct_navigation_action(
+ state: &AppState,
+ key: &KeyEvent,
+) -> Option<NavigateAction> {
+ let kb = &state.keybinds;
+ if kb
+ .previous_workspace
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::PreviousWorkspace);
+ }
+ if kb
+ .next_workspace
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::NextWorkspace);
+ }
+ if kb
+ .previous_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::PreviousTab);
+ }
+ if kb
+ .next_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::NextTab);
+ }
+ if kb
+ .focus_pane_left
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::FocusPaneLeft);
+ }
+ if kb
+ .focus_pane_down
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::FocusPaneDown);
+ }
+ if kb
+ .focus_pane_up
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::FocusPaneUp);
+ }
+ if kb
+ .focus_pane_right
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::FocusPaneRight);
+ }
+ None
+}
+
+impl App {
+ pub(super) fn handle_navigate_key(&mut self, key: KeyEvent) {
+ self.state.update_dismissed = true;
+
+ if self.state.is_prefix(&key) || key.code == KeyCode::Esc {
+ leave_navigate_mode(&mut self.state);
+ return;
+ }
+
+ if let Some(action) = navigate_action_for_key(&self.state, &key) {
+ execute_navigate_action(&mut self.state, action);
+ return;
+ }
+
+ if handle_navigate_reserved_key(&mut self.state, key) {
+ return;
+ }
+
+ if let Some(binding) = navigate_custom_command_for_key(&self.state, &key) {
+ self.launch_custom_command(binding);
+ }
+ }
+
+ fn launch_custom_command(&mut self, binding: crate::config::CustomCommandKeybind) {
+ let previous_toast = self.state.toast.clone();
+ let result = match binding.action {
+ crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding),
+ crate::config::CustomCommandAction::Pane => self.spawn_pane_command(&binding.command),
+ };
+ match result {
+ Ok(()) => leave_navigate_mode(&mut self.state),
+ Err(err) => {
+ self.state.toast = Some(crate::app::state::ToastNotification {
+ kind: crate::app::state::ToastKind::NeedsAttention,
+ title: "custom command failed".to_string(),
+ context: err.to_string(),
+ });
+ self.sync_toast_deadline(previous_toast);
+ }
+ }
+ }
+
+ fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) {
+ let mut env = vec![(
+ crate::api::SOCKET_PATH_ENV_VAR.to_string(),
+ crate::api::socket_path().display().to_string(),
+ )];
+ if let Ok(current_exe) = std::env::current_exe() {
+ env.push((
+ "HERDR_BIN_PATH".to_string(),
+ current_exe.display().to_string(),
+ ));
+ }
+
+ let mut cwd = None;
+ if let Some(ws_idx) = self.state.active {
+ env.push((
+ "HERDR_ACTIVE_WORKSPACE_ID".to_string(),
+ self.public_workspace_id(ws_idx),
+ ));
+ if let Some(workspace) = self.state.workspaces.get(ws_idx) {
+ let tab_idx = workspace.active_tab_index();
+ if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) {
+ env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id));
+ }
+ if let Some(pane_id) = workspace.focused_pane_id() {
+ if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) {
+ env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id));
+ }
+ if let Some(pane_cwd) = workspace
+ .active_tab()
+ .and_then(|tab| tab.cwd_for_pane(pane_id))
+ {
+ env.push((
+ "HERDR_ACTIVE_PANE_CWD".to_string(),
+ pane_cwd.display().to_string(),
+ ));
+ if pane_cwd.is_dir() {
+ cwd = Some(pane_cwd);
+ }
+ }
+ }
+ }
+ }
+ (env, cwd)
+ }
+
+ fn spawn_custom_command(
+ &self,
+ binding: &crate::config::CustomCommandKeybind,
+ ) -> std::io::Result<()> {
+ let mut command = Command::new("/bin/sh");
+ command
+ .arg("-lc")
+ .arg(&binding.command)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+ let (env, cwd) = self.custom_command_env();
+ command.envs(env);
+ if let Some(cwd) = cwd {
+ command.current_dir(cwd);
+ }
+ command.spawn()?;
+ Ok(())
+ }
+
+ fn spawn_pane_command(&mut self, command: &str) -> std::io::Result<()> {
+ let Some(ws_idx) = self.state.active else {
+ return Err(std::io::Error::other("no active workspace"));
+ };
+ let (rows, cols) = self.state.estimate_pane_size();
+ let new_rows = rows.max(4);
+ let new_cols = cols.max(10);
+ let (env, _) = self.custom_command_env();
+
+ let ws = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?;
+ let tab_idx = ws.active_tab_index();
+ let previous_focus = ws
+ .focused_pane_id()
+ .ok_or_else(|| std::io::Error::other("no focused pane"))?;
+ let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false);
+ let cwd = ws
+ .active_tab()
+ .and_then(|tab| tab.cwd_for_pane(previous_focus));
+ let new_pane_id = ws.split_focused_command(
+ Direction::Horizontal,
+ new_rows,
+ new_cols,
+ cwd,
+ command,
+ &env,
+ self.state.pane_scrollback_limit_bytes,
+ self.state.host_terminal_theme,
+ )?;
+ ws.active_tab_mut()
+ .expect("workspace must have an active tab")
+ .layout
+ .focus_pane(new_pane_id);
+ ws.active_tab_mut()
+ .expect("workspace must have an active tab")
+ .zoomed = true;
+ self.overlay_panes.insert(
+ new_pane_id,
+ super::super::OverlayPaneState {
+ ws_idx,
+ tab_idx,
+ previous_focus,
+ previous_zoomed,
+ },
+ );
+ self.state.mode = Mode::Terminal;
+ Ok(())
+ }
+}
+
+fn navigate_custom_command_for_key(
+ state: &AppState,
+ key: &KeyEvent,
+) -> Option<crate::config::CustomCommandKeybind> {
+ state
+ .keybinds
+ .custom_commands
+ .iter()
+ .find(|binding| key_matches(key, binding.key.0, binding.key.1))
+ .cloned()
+}
+
+pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) -> bool {
+ match key.code {
+ KeyCode::Char('q') => {
+ super::modal::request_quit_or_detach(state);
+ leave_navigate_mode(state);
+ true
+ }
+ KeyCode::Enter => {
+ if !state.workspaces.is_empty() {
+ state.switch_workspace(state.selected);
+ leave_navigate_mode(state);
+ }
+ true
+ }
+ KeyCode::Char(c @ '1'..='9') => {
+ let idx = (c as usize) - ('1' as usize);
+ if idx < state.workspaces.len() {
+ state.switch_workspace(idx);
+ leave_navigate_mode(state);
+ }
+ true
+ }
+ KeyCode::Char('s') => {
+ super::settings::open_settings(state);
+ true
+ }
+ KeyCode::Char('?') => {
+ super::modal::open_keybind_help(state);
+ true
+ }
+ KeyCode::Up => {
+ if state.selected > 0 {
+ state.selected -= 1;
+ state.ensure_workspace_visible(state.selected);
+ }
+ true
+ }
+ KeyCode::Down => {
+ if !state.workspaces.is_empty() && state.selected < state.workspaces.len() - 1 {
+ state.selected += 1;
+ state.ensure_workspace_visible(state.selected);
+ }
+ true
+ }
+ KeyCode::Char('h') | KeyCode::Left => {
+ state.navigate_pane(NavDirection::Left);
+ true
+ }
+ KeyCode::Char('j') => {
+ state.navigate_pane(NavDirection::Down);
+ true
+ }
+ KeyCode::Char('k') => {
+ state.navigate_pane(NavDirection::Up);
+ true
+ }
+ KeyCode::Char('l') | KeyCode::Right => {
+ state.navigate_pane(NavDirection::Right);
+ true
+ }
+ KeyCode::Tab => {
+ state.cycle_pane(false);
+ true
+ }
+ KeyCode::BackTab => {
+ state.cycle_pane(true);
+ true
+ }
+ _ => false,
+ }
+}
+
+#[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key
+pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
+ state.update_dismissed = true;
+
+ if state.is_prefix(&key) || key.code == KeyCode::Esc {
+ leave_navigate_mode(state);
+ return;
+ }
+
+ if let Some(action) = navigate_action_for_key(state, &key) {
+ execute_navigate_action(state, action);
+ return;
+ }
+
+ let _ = handle_navigate_reserved_key(state, key);
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum NavigateAction {
+ NewWorkspace,
+ RenameWorkspace,
+ CloseWorkspace,
+ PreviousWorkspace,
+ NextWorkspace,
+ NewTab,
+ RenameTab,
+ PreviousTab,
+ NextTab,
+ CloseTab,
+ FocusPaneLeft,
+ FocusPaneDown,
+ FocusPaneUp,
+ FocusPaneRight,
+ SplitVertical,
+ SplitHorizontal,
+ ClosePane,
+ Fullscreen,
+ EnterResizeMode,
+ ToggleSidebar,
+ Detach,
+}
+
+fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> {
+ let kb = &state.keybinds;
+ if key_matches(key, kb.new_workspace.0, kb.new_workspace.1) {
+ return Some(NavigateAction::NewWorkspace);
+ }
+ if key_matches(key, kb.rename_workspace.0, kb.rename_workspace.1) {
+ return Some(NavigateAction::RenameWorkspace);
+ }
+ if key_matches(key, kb.close_workspace.0, kb.close_workspace.1) {
+ return Some(NavigateAction::CloseWorkspace);
+ }
+ if kb
+ .previous_workspace
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::PreviousWorkspace);
+ }
+ if kb
+ .next_workspace
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::NextWorkspace);
+ }
+ if key_matches(key, kb.new_tab.0, kb.new_tab.1) {
+ return Some(NavigateAction::NewTab);
+ }
+ if kb
+ .rename_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::RenameTab);
+ }
+ if kb
+ .previous_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::PreviousTab);
+ }
+ if kb
+ .next_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::NextTab);
+ }
+ if kb
+ .close_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::CloseTab);
+ }
+ if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) {
+ return Some(NavigateAction::SplitVertical);
+ }
+ if key_matches(key, kb.split_horizontal.0, kb.split_horizontal.1) {
+ return Some(NavigateAction::SplitHorizontal);
+ }
+ if key_matches(key, kb.close_pane.0, kb.close_pane.1) {
+ return Some(NavigateAction::ClosePane);
+ }
+ if key_matches(key, kb.fullscreen.0, kb.fullscreen.1) {
+ return Some(NavigateAction::Fullscreen);
+ }
+ if key_matches(key, kb.resize_mode.0, kb.resize_mode.1) {
+ return Some(NavigateAction::EnterResizeMode);
+ }
+ if key_matches(key, kb.toggle_sidebar.0, kb.toggle_sidebar.1) {
+ return Some(NavigateAction::ToggleSidebar);
+ }
+ if kb
+ .detach
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::Detach);
+ }
+ None
+}
+
+pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) {
+ match action {
+ NavigateAction::NewWorkspace => {
+ state.request_new_workspace = true;
+ leave_navigate_mode(state);
+ }
+ NavigateAction::RenameWorkspace => {
+ if !state.workspaces.is_empty() {
+ super::modal::open_rename_workspace(state, state.selected);
+ }
+ }
+ NavigateAction::CloseWorkspace => {
+ if !state.workspaces.is_empty() {
+ if state.confirm_close {
+ super::modal::open_confirm_close(state);
+ } else {
+ state.close_selected_workspace();
+ leave_navigate_mode(state);
+ }
+ }
+ }
+ NavigateAction::PreviousWorkspace => {
+ state.previous_workspace();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::NextWorkspace => {
+ state.next_workspace();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::NewTab => super::modal::open_new_tab_dialog(state),
+ NavigateAction::RenameTab => super::modal::open_rename_active_tab(state, false),
+ NavigateAction::PreviousTab => {
+ state.previous_tab();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::NextTab => {
+ state.next_tab();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::CloseTab => {
+ state.close_tab();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::FocusPaneLeft => state.navigate_pane(NavDirection::Left),
+ NavigateAction::FocusPaneDown => state.navigate_pane(NavDirection::Down),
+ NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up),
+ NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right),
+ NavigateAction::SplitVertical => {
+ state.split_pane(Direction::Horizontal);
+ leave_navigate_mode(state);
+ }
+ NavigateAction::SplitHorizontal => {
+ state.split_pane(Direction::Vertical);
+ leave_navigate_mode(state);
+ }
+ NavigateAction::ClosePane => {
+ state.close_pane();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::Fullscreen => {
+ state.toggle_fullscreen();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::EnterResizeMode => state.mode = Mode::Resize,
+ NavigateAction::ToggleSidebar => {
+ state.sidebar_collapsed = !state.sidebar_collapsed;
+ leave_navigate_mode(state);
+ }
+ NavigateAction::Detach => {
+ state.detach_requested = true;
+ leave_navigate_mode(state);
+ }
+ }
+}
+
+fn leave_navigate_mode(state: &mut AppState) {
+ if state.active.is_some() {
+ state.mode = Mode::Terminal;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::time::Duration;
+
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+ use ratatui::layout::{Direction, Rect};
+
+ use super::super::{state_with_workspaces, unique_temp_path, wait_for_file};
+ use super::*;
+ use crate::{app::App, config::Config, input::TerminalKey, workspace::Workspace};
+
+ #[test]
+ fn custom_rename_key_enters_rename_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.rename_workspace = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.rename_workspace_label = "g".into();
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::RenameWorkspace);
+ assert_eq!(state.name_input, "test");
+ }
+
+ #[test]
+ fn custom_new_workspace_key_requests_and_exits_navigate() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.new_workspace = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.new_workspace_label = "g".into();
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert!(state.request_new_workspace);
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn custom_sidebar_toggle_key_toggles_and_exits_navigate() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.toggle_sidebar = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.toggle_sidebar_label = "g".into();
+ assert!(!state.sidebar_collapsed);
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert!(state.sidebar_collapsed);
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn custom_resize_key_enters_resize_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.resize_mode_label = "g".into();
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Resize);
+ }
+
+ #[test]
+ fn movement_action_stays_in_navigate_mode() {
+ let mut state = state_with_workspaces(&["a", "b"]);
+ state.selected = 0;
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.selected, 1);
+ assert_eq!(state.mode, Mode::Navigate);
+ }
+
+ #[test]
+ fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.focus_pane_left = Some((KeyCode::Left, KeyModifiers::ALT));
+ state.keybinds.focus_pane_left_label = Some("alt+left".into());
+
+ let action = terminal_direct_navigation_action(
+ &state,
+ &KeyEvent::new(KeyCode::Left, KeyModifiers::ALT),
+ );
+
+ assert_eq!(action, Some(NavigateAction::FocusPaneLeft));
+ }
+
+ #[tokio::test]
+ async fn custom_command_runs_from_prefix_key_in_navigate_mode() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ let output_path = unique_temp_path("custom-command-keybind");
+ let command = format!(
+ "printf '%s\\n%s\\n%s\\n' \"$HERDR_ACTIVE_WORKSPACE_ID\" \"$HERDR_ACTIVE_TAB_ID\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'",
+ output_path.display()
+ );
+ app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
+ key: (KeyCode::Char('g'), KeyModifiers::empty()),
+ label: "g".into(),
+ command,
+ action: crate::config::CustomCommandAction::Shell,
+ }];
+
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ assert_eq!(app.state.mode, Mode::Navigate);
+
+ app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()))
+ .await;
+
+ let content = wait_for_file(&output_path);
+ let lines: Vec<&str> = content.lines().collect();
+ assert_eq!(lines.len(), 3);
+ assert_eq!(lines[0], app.state.workspaces[0].id);
+ assert_eq!(lines[1], format!("{}:1", app.state.workspaces[0].id));
+ assert_eq!(lines[2], format!("{}-1", app.state.workspaces[0].id));
+ assert_eq!(app.state.mode, Mode::Terminal);
+
+ let _ = std::fs::remove_file(output_path);
+ }
+
+ #[tokio::test]
+ async fn pane_overlay_command_opens_and_closes_after_exit() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ let workspace = Workspace::new(
+ std::env::current_dir().unwrap_or_else(|_| "/".into()),
+ 24,
+ 80,
+ app.state.pane_scrollback_limit_bytes,
+ app.state.host_terminal_theme,
+ app.event_tx.clone(),
+ app.render_notify.clone(),
+ app.render_dirty.clone(),
+ )
+ .expect("workspace should spawn");
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ let output_path = unique_temp_path("custom-pane-command");
+ let command = format!("printf done > '{}'", output_path.display());
+ app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
+ key: (KeyCode::Char('g'), KeyModifiers::empty()),
+ label: "g".into(),
+ command,
+ action: crate::config::CustomCommandAction::Pane,
+ }];
+
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()))
+ .await;
+
+ assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2);
+ assert!(app.state.workspaces[0].tabs[0].zoomed);
+
+ let _ = wait_for_file(&output_path);
+ let deadline = std::time::Instant::now() + Duration::from_secs(2);
+ while std::time::Instant::now() < deadline {
+ if app.drain_internal_events()
+ && app.state.workspaces[0].tabs[0].layout.pane_count() == 1
+ {
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(20));
+ }
+
+ assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1);
+ assert!(!app.state.workspaces[0].tabs[0].zoomed);
+ assert_eq!(app.state.mode, Mode::Terminal);
+ let _ = std::fs::remove_file(output_path);
+ }
+
+ #[test]
+ fn fullscreen_action_exits_navigate_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.workspaces[0].test_split(Direction::Horizontal);
+ state.keybinds.fullscreen = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.fullscreen_label = "g".into();
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert!(state.workspaces[0].zoomed);
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn question_mark_opens_keybind_help_from_navigate() {
+ let mut state = state_with_workspaces(&["test"]);
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT),
+ );
+
+ assert_eq!(state.mode, Mode::KeybindHelp);
+ }
+
+ #[test]
+ fn new_tab_action_opens_dialog_without_creating_tab() {
+ let mut state = state_with_workspaces(&["test"]);
+
+ execute_navigate_action(&mut state, NavigateAction::NewTab);
+
+ assert_eq!(state.mode, Mode::RenameTab);
+ assert!(state.creating_new_tab);
+ assert_eq!(state.name_input, "2");
+ assert!(state.name_input_replace_on_type);
+ assert!(!state.request_new_tab);
+ assert_eq!(state.workspaces[0].tabs.len(), 1);
+ }
+
+ #[test]
+ fn persistence_mode_navigate_q_detaches_instead_of_quitting_server() {
+ let mut state = crate::app::state::AppState::test_new();
+ state.quit_detaches = true;
+
+ assert!(handle_navigate_reserved_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())
+ ));
+ assert!(state.detach_requested);
+ assert!(!state.should_quit);
+ }
+}
diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs
new file mode 100644
index 00000000..6475895e
--- /dev/null
+++ b/src/app/input/overlays.rs
@@ -0,0 +1,483 @@
+use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
+use ratatui::{
+ layout::Rect,
+ widgets::{Block, Borders},
+};
+
+use crate::app::{
+ state::{AppState, DragState, DragTarget, Mode},
+ App,
+};
+
+use super::{
+ modal::{leave_modal, modal_action_from_buttons, ModalAction},
+ ScrollbarClickTarget,
+};
+
+impl App {
+ pub(super) fn handle_overlay_mouse(&mut self, mouse: MouseEvent) -> bool {
+ if self.state.mode == Mode::ReleaseNotes {
+ match mouse.kind {
+ MouseEventKind::Down(MouseButton::Left)
+ if self
+ .state
+ .release_notes_close_button_at(mouse.column, mouse.row) =>
+ {
+ self.dismiss_release_notes();
+ }
+ MouseEventKind::Down(MouseButton::Left) => {
+ if let Some(target) = self
+ .state
+ .release_notes_scrollbar_target_at(mouse.column, mouse.row)
+ {
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.state.drag = Some(DragState {
+ target: DragTarget::ReleaseNotesScrollbar { grab_row_offset },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => {
+ self.state
+ .set_release_notes_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ }
+ }
+ MouseEventKind::Drag(MouseButton::Left) => {
+ if let Some(DragState {
+ target: DragTarget::ReleaseNotesScrollbar { grab_row_offset },
+ }) = &self.state.drag
+ {
+ if let Some(offset_from_bottom) = self
+ .state
+ .release_notes_offset_for_drag_row(mouse.row, *grab_row_offset)
+ {
+ self.state
+ .set_release_notes_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ }
+ MouseEventKind::Up(MouseButton::Left) => {
+ self.state.drag = None;
+ }
+ MouseEventKind::ScrollUp => self.scroll_release_notes(-3),
+ MouseEventKind::ScrollDown => self.scroll_release_notes(3),
+ _ => {}
+ }
+ return true;
+ }
+
+ if self.state.mode == Mode::KeybindHelp {
+ match mouse.kind {
+ MouseEventKind::Down(MouseButton::Left)
+ if self
+ .state
+ .keybind_help_close_button_at(mouse.column, mouse.row) =>
+ {
+ leave_modal(&mut self.state);
+ }
+ MouseEventKind::Down(MouseButton::Left) => {
+ if let Some(target) = self
+ .state
+ .keybind_help_scrollbar_target_at(mouse.column, mouse.row)
+ {
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.state.drag = Some(DragState {
+ target: DragTarget::KeybindHelpScrollbar { grab_row_offset },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => {
+ self.state
+ .set_keybind_help_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ } else {
+ let rect = self.state.keybind_help_popup_rect();
+ let inside = mouse.column >= rect.x
+ && mouse.column < rect.x + rect.width
+ && mouse.row >= rect.y
+ && mouse.row < rect.y + rect.height;
+ if !inside {
+ leave_modal(&mut self.state);
+ }
+ }
+ }
+ MouseEventKind::Drag(MouseButton::Left) => {
+ if let Some(DragState {
+ target: DragTarget::KeybindHelpScrollbar { grab_row_offset },
+ }) = &self.state.drag
+ {
+ if let Some(offset_from_bottom) = self
+ .state
+ .keybind_help_offset_for_drag_row(mouse.row, *grab_row_offset)
+ {
+ self.state
+ .set_keybind_help_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ }
+ MouseEventKind::Up(MouseButton::Left) => {
+ self.state.drag = None;
+ }
+ MouseEventKind::ScrollUp => self.state.scroll_keybind_help(-3),
+ MouseEventKind::ScrollDown => self.state.scroll_keybind_help(3),
+ _ => {}
+ }
+ return true;
+ }
+
+ false
+ }
+}
+
+impl AppState {
+ pub(super) fn onboarding_full_area(&self) -> Rect {
+ self.view.sidebar_rect.union(self.view.terminal_area)
+ }
+
+ pub(super) fn onboarding_modal_inner(&self, popup_w: u16, popup_h: u16) -> Option<Rect> {
+ let area = self.onboarding_full_area();
+ let popup_w = popup_w.min(area.width.saturating_sub(4));
+ let popup_h = popup_h.min(area.height.saturating_sub(2));
+ if popup_w < 4 || popup_h < 4 {
+ return None;
+ }
+ let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2;
+ let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2;
+ let popup = Rect::new(popup_x, popup_y, popup_w, popup_h);
+ Some(Block::default().borders(Borders::ALL).inner(popup))
+ }
+
+ fn release_notes_modal_inner(&self) -> Option<Rect> {
+ self.onboarding_modal_inner(
+ crate::ui::RELEASE_NOTES_MODAL_SIZE.0,
+ crate::ui::RELEASE_NOTES_MODAL_SIZE.1,
+ )
+ }
+
+ fn release_notes_close_button_at(&self, col: u16, row: u16) -> bool {
+ let Some(inner) = self.release_notes_modal_inner() else {
+ return false;
+ };
+ if inner.height < 4 || inner.width < 12 {
+ return false;
+ }
+ let button =
+ crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1));
+ col >= button.x
+ && col < button.x + button.width
+ && row >= button.y
+ && row < button.y + button.height
+ }
+
+ pub(super) fn rename_modal_inner(&self) -> Option<Rect> {
+ self.onboarding_modal_inner(56, 7)
+ }
+
+ fn release_notes_body_rect(&self) -> Option<Rect> {
+ let inner = self.release_notes_modal_inner()?;
+ if inner.height < 8 || inner.width < 4 {
+ return None;
+ }
+ let body = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content;
+ let preview = self
+ .release_notes
+ .as_ref()
+ .is_some_and(|notes| notes.preview);
+ Some(crate::ui::release_notes_sections(body, preview).notes_body)
+ }
+
+ fn release_notes_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> {
+ let notes = self.release_notes.as_ref()?;
+ let body = self.release_notes_body_rect()?;
+ let viewport_rows = body.height.max(1) as usize;
+ let lines = crate::ui::release_notes_display_lines(notes, &self.palette);
+
+ let rows_for_width = |wrap_width: usize| {
+ lines
+ .iter()
+ .map(|(width, _)| width.max(&1).div_ceil(wrap_width.max(1)))
+ .sum::<usize>()
+ };
+
+ let full_width = body.width.max(1) as usize;
+ let mut total_rows = rows_for_width(full_width);
+ let wrap_width = if total_rows > viewport_rows && full_width > 1 {
+ body.width.saturating_sub(1).max(1) as usize
+ } else {
+ full_width
+ };
+ total_rows = rows_for_width(wrap_width);
+
+ let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
+ Some(crate::pane::ScrollMetrics {
+ offset_from_bottom: max_offset_from_bottom.saturating_sub(notes.scroll as usize),
+ max_offset_from_bottom,
+ viewport_rows,
+ })
+ }
+
+ pub(crate) fn release_notes_max_scroll(&self) -> u16 {
+ self.release_notes_scroll_metrics()
+ .map(|metrics| metrics.max_offset_from_bottom as u16)
+ .unwrap_or(0)
+ }
+
+ fn release_notes_scrollbar_target_at(
+ &self,
+ col: u16,
+ row: u16,
+ ) -> Option<ScrollbarClickTarget> {
+ let body = self.release_notes_body_rect()?;
+ let metrics = self.release_notes_scroll_metrics()?;
+ let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
+ 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 release_notes_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> {
+ let body = self.release_notes_body_rect()?;
+ let metrics = self.release_notes_scroll_metrics()?;
+ let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
+ Some(crate::ui::scrollbar_offset_from_drag_row(
+ metrics,
+ track,
+ row,
+ grab_row_offset,
+ ))
+ }
+
+ fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) {
+ let max_scroll = self.release_notes_max_scroll() as usize;
+ if let Some(notes) = &mut self.release_notes {
+ notes.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16;
+ }
+ }
+
+ pub(super) fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) {
+ if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
+ return;
+ }
+
+ match self.onboarding_step {
+ 0 => {
+ let Some(inner) = self.onboarding_modal_inner(64, 16) else {
+ return;
+ };
+ let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1)
+ .actions
+ .unwrap_or_default();
+ let button = crate::ui::onboarding_welcome_continue_rect(actions);
+ if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
+ && modal_action_from_buttons(
+ mouse.column,
+ mouse.row,
+ &[(button, ModalAction::Continue)],
+ ) == Some(ModalAction::Continue)
+ {
+ self.onboarding_step = 1;
+ }
+ }
+ _ => {
+ let Some(inner) = self.onboarding_modal_inner(56, 14) else {
+ return;
+ };
+ let stack = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1);
+ if mouse.row >= stack.content.y && mouse.row < stack.content.y + 4 {
+ self.onboarding_list
+ .select((mouse.row - stack.content.y) as usize);
+ return;
+ }
+
+ let (back, save) = crate::ui::onboarding_notification_button_rects(
+ stack.actions.unwrap_or_default(),
+ );
+ if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
+ match modal_action_from_buttons(
+ mouse.column,
+ mouse.row,
+ &[(back, ModalAction::Back), (save, ModalAction::Save)],
+ ) {
+ Some(ModalAction::Back) => self.onboarding_step = 0,
+ Some(ModalAction::Save) => self.request_complete_onboarding = true,
+ _ => {}
+ }
+ }
+ }
+ }
+ }
+
+ pub(super) fn keybind_help_popup_rect(&self) -> Rect {
+ crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default()
+ }
+
+ fn keybind_help_modal_inner(&self) -> Option<Rect> {
+ self.onboarding_modal_inner(76, 22)
+ }
+
+ fn keybind_help_close_button_at(&self, col: u16, row: u16) -> bool {
+ let Some(inner) = self.keybind_help_modal_inner() else {
+ return false;
+ };
+ if inner.height < 4 || inner.width < 12 {
+ return false;
+ }
+ let button =
+ crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1));
+ col >= button.x
+ && col < button.x + button.width
+ && row >= button.y
+ && row < button.y + button.height
+ }
+
+ fn keybind_help_body_rect(&self) -> Option<Rect> {
+ let inner = self.keybind_help_modal_inner()?;
+ if inner.height < 6 || inner.width < 4 {
+ return None;
+ }
+ Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content)
+ }
+
+ fn keybind_help_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> {
+ let body = self.keybind_help_body_rect()?;
+ let viewport_rows = body.height.max(1) as usize;
+ let wrap_width = body.width.max(1) as usize;
+ let total_rows = crate::ui::keybind_help_lines(self)
+ .into_iter()
+ .map(|(width, _)| width.max(1).div_ceil(wrap_width))
+ .sum::<usize>();
+ let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
+ Some(crate::pane::ScrollMetrics {
+ offset_from_bottom: max_offset_from_bottom
+ .saturating_sub(self.keybind_help.scroll as usize),
+ max_offset_from_bottom,
+ viewport_rows,
+ })
+ }
+
+ fn keybind_help_scrollbar_target_at(&self, col: u16, row: u16) -> Option<ScrollbarClickTarget> {
+ let body = self.keybind_help_body_rect()?;
+ let metrics = self.keybind_help_scroll_metrics()?;
+ let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
+ 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 keybind_help_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> {
+ let body = self.keybind_help_body_rect()?;
+ let metrics = self.keybind_help_scroll_metrics()?;
+ let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
+ Some(crate::ui::scrollbar_offset_from_drag_row(
+ metrics,
+ track,
+ row,
+ grab_row_offset,
+ ))
+ }
+
+ pub(crate) fn keybind_help_max_scroll(&self) -> u16 {
+ self.keybind_help_scroll_metrics()
+ .map(|metrics| metrics.max_offset_from_bottom as u16)
+ .unwrap_or(0)
+ }
+
+ fn set_keybind_help_offset_from_bottom(&mut self, offset_from_bottom: usize) {
+ let max_scroll = self.keybind_help_max_scroll() as usize;
+ self.keybind_help.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16;
+ }
+
+ pub(super) fn scroll_keybind_help(&mut self, delta: i16) {
+ let max_scroll = self.keybind_help_max_scroll();
+ let current = self.keybind_help.scroll as i16;
+ self.keybind_help.scroll = current.saturating_add(delta).clamp(0, max_scroll as i16) as u16;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crossterm::event::{MouseButton, MouseEventKind};
+ use ratatui::layout::Rect;
+
+ use super::super::{app_for_mouse_test, mouse};
+ use super::*;
+
+ #[test]
+ fn clicking_keybind_help_close_button_closes_overlay() {
+ let mut app = app_for_mouse_test();
+ app.state.mode = Mode::KeybindHelp;
+
+ let rect = app.state.keybind_help_popup_rect();
+ let inner = Rect::new(
+ rect.x + 1,
+ rect.y + 1,
+ rect.width.saturating_sub(2),
+ rect.height.saturating_sub(2),
+ );
+ let close =
+ crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1));
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ close.x,
+ close.y,
+ ));
+
+ assert_eq!(app.state.mode, Mode::Navigate);
+ }
+
+ #[test]
+ fn onboarding_hover_does_not_change_selection() {
+ let mut app = app_for_mouse_test();
+ app.state.mode = Mode::Onboarding;
+ app.state.onboarding_step = 1;
+ app.state.onboarding_list.select(1);
+
+ let inner = app.state.onboarding_modal_inner(56, 14).unwrap();
+ let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content;
+ app.handle_mouse(mouse(MouseEventKind::Moved, content.x + 2, content.y));
+
+ assert_eq!(app.state.onboarding_list.selected, 1);
+ }
+
+ #[test]
+ fn onboarding_click_selects_notification_option() {
+ let mut app = app_for_mouse_test();
+ app.state.mode = Mode::Onboarding;
+ app.state.onboarding_step = 1;
+ app.state.onboarding_list.select(0);
+
+ let inner = app.state.onboarding_modal_inner(56, 14).unwrap();
+ let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content;
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ content.x + 2,
+ content.y + 2,
+ ));
+
+ assert_eq!(app.state.onboarding_list.selected, 2);
+ }
+}
diff --git a/src/app/input/selection.rs b/src/app/input/selection.rs
new file mode 100644
index 00000000..953e9b26
--- /dev/null
+++ b/src/app/input/selection.rs
@@ -0,0 +1,68 @@
+use crossterm::event::{MouseEvent, MouseEventKind};
+
+use crate::app::state::AppState;
+
+impl AppState {
+ fn update_selection_cursor(
+ &mut self,
+ pane_id: crate::layout::PaneId,
+ screen_col: u16,
+ screen_row: u16,
+ ) {
+ let Some(info) = self.pane_info_by_id(pane_id).cloned() else {
+ return;
+ };
+ let metrics = self.pane_scroll_metrics(pane_id);
+ if let Some(selection) = self.selection.as_mut() {
+ selection.drag(screen_col, screen_row, info.inner_rect, metrics);
+ }
+ }
+
+ fn selection_edge_scroll_lines(distance: u16) -> usize {
+ usize::from(distance).saturating_mul(3).clamp(3, 15)
+ }
+
+ pub(super) fn update_selection_drag(&mut self, screen_col: u16, screen_row: u16) {
+ let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else {
+ return;
+ };
+ let Some(info) = self.pane_info_by_id(pane_id).cloned() else {
+ return;
+ };
+
+ let bottom = info.inner_rect.y + info.inner_rect.height.saturating_sub(1);
+ if screen_row < info.inner_rect.y {
+ self.scroll_pane_up(
+ pane_id,
+ Self::selection_edge_scroll_lines(info.inner_rect.y - screen_row),
+ );
+ } else if screen_row > bottom {
+ self.scroll_pane_down(
+ pane_id,
+ Self::selection_edge_scroll_lines(screen_row - bottom),
+ );
+ }
+
+ self.update_selection_cursor(pane_id, screen_col, screen_row);
+ }
+
+ pub(super) fn scroll_selection_with_wheel(&mut self, mouse: MouseEvent) -> bool {
+ const LINES_PER_NOTCH: usize = 3;
+
+ let Some(selection) = self.selection.as_ref() else {
+ return false;
+ };
+ if !selection.is_in_progress() {
+ return false;
+ }
+ let pane_id = selection.pane_id;
+ self.focus_pane(pane_id);
+ match mouse.kind {
+ MouseEventKind::ScrollUp => self.scroll_pane_up(pane_id, LINES_PER_NOTCH),
+ MouseEventKind::ScrollDown => self.scroll_pane_down(pane_id, LINES_PER_NOTCH),
+ _ => return false,
+ }
+ self.update_selection_cursor(pane_id, mouse.column, mouse.row);
+ true
+ }
+}
diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs
new file mode 100644
index 00000000..9433b7d8
--- /dev/null
+++ b/src/app/input/settings.rs
@@ -0,0 +1,352 @@
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::Rect;
+
+use crate::app::{
+ state::{AppState, SettingsSection, THEME_NAMES},
+ App, Mode,
+};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) enum SettingsAction {
+ SaveTheme(String),
+ SaveSound(bool),
+ SaveToast(bool),
+}
+
+impl App {
+ pub(crate) fn handle_settings_key(&mut self, key: KeyEvent) {
+ if let Some(action) = update_settings_state(&mut self.state, key) {
+ match action {
+ SettingsAction::SaveTheme(name) => self.save_theme(&name),
+ SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
+ SettingsAction::SaveToast(enabled) => self.save_toast(enabled),
+ }
+ }
+ }
+}
+
+fn normalize_theme_name(name: &str) -> String {
+ name.to_lowercase().replace([' ', '_'], "-")
+}
+
+fn current_theme_index(theme_name: &str) -> usize {
+ let normalized = normalize_theme_name(theme_name);
+ THEME_NAMES
+ .iter()
+ .position(|name| normalize_theme_name(name) == normalized)
+ .unwrap_or(0)
+}
+
+fn preview_selected_theme(state: &mut AppState) {
+ use crate::app::state::Palette;
+
+ let name = THEME_NAMES[state.settings.list.selected];
+ if let Some(palette) = Palette::from_name(name) {
+ state.palette = palette;
+ state.theme_name = name.to_string();
+ }
+}
+
+fn cancel_settings(state: &mut AppState) {
+ if let Some(palette) = state.settings.original_palette.take() {
+ state.palette = palette;
+ }
+ if let Some(theme_name) = state.settings.original_theme.take() {
+ state.theme_name = theme_name;
+ }
+ super::modal::leave_modal(state);
+}
+
+fn apply_settings(state: &mut AppState) -> Option<SettingsAction> {
+ match state.settings.section {
+ SettingsSection::Theme => {
+ let theme_name = state.theme_name.clone();
+ state.settings.original_palette = None;
+ state.settings.original_theme = None;
+ super::modal::leave_modal(state);
+ Some(SettingsAction::SaveTheme(theme_name))
+ }
+ _ => {
+ super::modal::leave_modal(state);
+ None
+ }
+ }
+}
+
+pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<SettingsAction> {
+ match state.settings.section {
+ SettingsSection::Theme => match key.code {
+ KeyCode::Up | KeyCode::Char('k') => {
+ let previous = state.settings.list.selected;
+ state.settings.list.move_prev();
+ if state.settings.list.selected != previous {
+ preview_selected_theme(state);
+ }
+ }
+ KeyCode::Down | KeyCode::Char('j') => {
+ let previous = state.settings.list.selected;
+ state.settings.list.move_next(THEME_NAMES.len());
+ if state.settings.list.selected != previous {
+ preview_selected_theme(state);
+ }
+ }
+ KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
+ state.settings.section = SettingsSection::Sound;
+ state.settings.list.selected = usize::from(!state.sound_enabled());
+ }
+ _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) {
+ Some(super::modal::ModalAction::Apply) => return apply_settings(state),
+ Some(super::modal::ModalAction::Close) => cancel_settings(state),
+ _ => {}
+ },
+ },
+ SettingsSection::Sound => match key.code {
+ KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
+ state.settings.list.selected = 1 - state.settings.list.selected.min(1);
+ }
+ KeyCode::Enter | KeyCode::Char(' ') => {
+ let enabled = state.settings.list.selected == 0;
+ state.sound.enabled = enabled;
+ return Some(SettingsAction::SaveSound(enabled));
+ }
+ KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
+ state.settings.section = SettingsSection::Toast;
+ state.settings.list.selected = usize::from(!state.toast_config.enabled);
+ }
+ KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
+ state.settings.section = SettingsSection::Theme;
+ state.settings.list.selected = current_theme_index(&state.theme_name);
+ }
+ _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) {
+ Some(super::modal::ModalAction::Close) => cancel_settings(state),
+ _ => {}
+ },
+ },
+ SettingsSection::Toast => match key.code {
+ KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
+ state.settings.list.selected = 1 - state.settings.list.selected.min(1);
+ }
+ KeyCode::Enter | KeyCode::Char(' ') => {
+ let enabled = state.settings.list.selected == 0;
+ state.toast_config.enabled = enabled;
+ return Some(SettingsAction::SaveToast(enabled));
+ }
+ KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
+ state.settings.section = SettingsSection::Sound;
+ state.settings.list.selected = usize::from(!state.sound_enabled());
+ }
+ KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
+ state.settings.section = SettingsSection::Theme;
+ state.settings.list.selected = current_theme_index(&state.theme_name);
+ }
+ _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) {
+ Some(super::modal::ModalAction::Close) => cancel_settings(state),
+ _ => {}
+ },
+ },
+ }
+
+ None
+}
+
+pub(super) fn open_settings(state: &mut AppState) {
+ state.settings.original_palette = Some(state.palette.clone());
+ state.settings.original_theme = Some(state.theme_name.clone());
+ state.settings.section = SettingsSection::Theme;
+ state.settings.list.selected = current_theme_index(&state.theme_name);
+ state.mode = Mode::Settings;
+}
+
+impl AppState {
+ fn settings_popup_rect(&self) -> Rect {
+ crate::ui::centered_popup_rect(self.screen_rect(), 56, 20).unwrap_or_default()
+ }
+
+ fn settings_inner_rect(&self) -> Rect {
+ let popup = self.settings_popup_rect();
+ Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ )
+ }
+
+ fn settings_tab_at(&self, col: u16, row: u16) -> Option<SettingsSection> {
+ let inner = self.settings_inner_rect();
+ let tab_y = inner.y + 1;
+ if row != tab_y {
+ return None;
+ }
+ let mut x = inner.x;
+ for section in SettingsSection::ALL {
+ let width = section.label().len() as u16 + 2;
+ if col >= x && col < x + width {
+ return Some(*section);
+ }
+ x += width + 1;
+ }
+ None
+ }
+
+ pub(crate) fn settings_content_rect(&self) -> Rect {
+ let inner = self.settings_inner_rect();
+ crate::ui::modal_stack_areas(inner, 3, 2, 0, 1).content
+ }
+
+ fn settings_list_index_at(&self, col: u16, row: u16) -> Option<usize> {
+ let area = self.settings_content_rect();
+ if row < area.y || row >= area.y + area.height || col < area.x || col >= area.x + area.width
+ {
+ return None;
+ }
+
+ match self.settings.section {
+ SettingsSection::Theme => {
+ let max_visible = area.height as usize;
+ let scroll = if self.settings.list.selected >= max_visible {
+ self.settings.list.selected - max_visible + 1
+ } else {
+ 0
+ };
+ let idx = scroll + (row - area.y) as usize;
+ (idx < THEME_NAMES.len()).then_some(idx)
+ }
+ SettingsSection::Sound | SettingsSection::Toast => {
+ let list_y = area.y + 2;
+ if row >= list_y && row < list_y + 2 {
+ Some((row - list_y) as usize)
+ } else {
+ None
+ }
+ }
+ }
+ }
+
+ pub(super) fn handle_settings_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> {
+ match mouse.kind {
+ MouseEventKind::Down(MouseButton::Left) => {
+ if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) {
+ self.settings.section = section;
+ self.settings.list.select(match section {
+ SettingsSection::Theme => current_theme_index(&self.theme_name),
+ SettingsSection::Sound => usize::from(!self.sound_enabled()),
+ SettingsSection::Toast => usize::from(!self.toast_config.enabled),
+ });
+ return None;
+ }
+ if let Some(idx) = self.settings_list_index_at(mouse.column, mouse.row) {
+ self.settings.list.select(idx);
+ return match self.settings.section {
+ SettingsSection::Theme => {
+ preview_selected_theme(self);
+ None
+ }
+ SettingsSection::Sound => {
+ let enabled = idx == 0;
+ self.sound.enabled = enabled;
+ Some(SettingsAction::SaveSound(enabled))
+ }
+ SettingsSection::Toast => {
+ let enabled = idx == 0;
+ self.toast_config.enabled = enabled;
+ Some(SettingsAction::SaveToast(enabled))
+ }
+ };
+ }
+
+ let inner = self.settings_inner_rect();
+ let (apply, close) = crate::ui::settings_button_rects(inner);
+ match super::modal::modal_action_from_buttons(
+ mouse.column,
+ mouse.row,
+ &[
+ (apply, super::modal::ModalAction::Apply),
+ (close, super::modal::ModalAction::Close),
+ ],
+ ) {
+ Some(super::modal::ModalAction::Apply) => apply_settings(self),
+ Some(super::modal::ModalAction::Close) => {
+ cancel_settings(self);
+ None
+ }
+ _ => {
+ cancel_settings(self);
+ None
+ }
+ }
+ }
+ _ => None,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind};
+
+ use super::super::{app_for_mouse_test, mouse, state_with_workspaces};
+ use super::*;
+
+ #[test]
+ fn settings_cancel_restores_previewed_theme_from_other_sections() {
+ let mut state = state_with_workspaces(&["test"]);
+ let original_palette = state.palette.clone();
+ let original_theme = state.theme_name.clone();
+
+ open_settings(&mut state);
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
+ );
+ assert_ne!(state.theme_name, original_theme);
+
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()),
+ );
+ assert_eq!(
+ state.settings.section,
+ crate::app::state::SettingsSection::Sound
+ );
+
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ assert_eq!(state.theme_name, original_theme);
+ assert_eq!(state.palette.accent, original_palette.accent);
+ assert_eq!(state.palette.panel_bg, original_palette.panel_bg);
+ }
+
+ #[test]
+ fn settings_sound_toggle_returns_save_action() {
+ let mut state = state_with_workspaces(&["test"]);
+ open_settings(&mut state);
+ state.settings.section = crate::app::state::SettingsSection::Sound;
+ state.settings.list.selected = 0;
+
+ let action = update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ assert_eq!(action, Some(SettingsAction::SaveSound(true)));
+ assert!(state.sound.enabled);
+ assert_eq!(state.mode, Mode::Settings);
+ }
+
+ #[test]
+ fn settings_hover_does_not_change_selection() {
+ let mut app = app_for_mouse_test();
+ open_settings(&mut app.state);
+ app.state.settings.list.select(0);
+
+ let area = app.state.settings_content_rect();
+ app.handle_mouse(mouse(MouseEventKind::Moved, area.x + 2, area.y + 2));
+
+ assert_eq!(app.state.settings.list.selected, 0);
+ }
+}
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
new file mode 100644
index 00000000..34845861
--- /dev/null
+++ b/src/app/input/sidebar.rs
@@ -0,0 +1,1219 @@
+use ratatui::layout::Rect;
+
+use crate::app::state::{AppState, Mode};
+
+use super::ScrollbarClickTarget;
+
+impl AppState {
+ pub(super) fn workspace_list_rect(&self) -> Rect {
+ let sidebar = self.view.sidebar_rect;
+ if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 {
+ return Rect::default();
+ }
+ crate::ui::workspace_list_rect(sidebar, self.sidebar_section_split)
+ }
+
+ pub(super) 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
+ }
+
+ pub(super) fn workspace_list_scrollbar_target_at(
+ &self,
+ col: u16,
+ row: u16,
+ ) -> Option<ScrollbarClickTarget> {
+ let area = self.workspace_list_rect();
+ let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
+ let track = crate::ui::workspace_list_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),
+ })
+ }
+ }
+
+ pub(super) fn workspace_list_offset_for_drag_row(
+ &self,
+ row: u16,
+ grab_row_offset: u16,
+ ) -> Option<usize> {
+ let area = self.workspace_list_rect();
+ let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
+ let track = crate::ui::workspace_list_scrollbar_rect(self, area)?;
+ Some(crate::ui::scrollbar_offset_from_drag_row(
+ metrics,
+ track,
+ row,
+ grab_row_offset,
+ ))
+ }
+
+ pub(super) fn set_workspace_list_offset_from_bottom(&mut self, offset_from_bottom: usize) {
+ let area = self.workspace_list_rect();
+ let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
+ self.workspace_scroll = metrics
+ .max_offset_from_bottom
+ .saturating_sub(offset_from_bottom);
+ }
+
+ pub(super) fn scroll_workspace_list(&mut self, delta: i16) {
+ if delta.is_negative() {
+ self.workspace_scroll = self
+ .workspace_scroll
+ .saturating_sub(delta.unsigned_abs() as usize);
+ return;
+ }
+
+ for _ in 0..delta as usize {
+ let cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect);
+ let Some(last) = cards.last() else {
+ break;
+ };
+ if last.ws_idx + 1 >= self.workspaces.len() {
+ break;
+ }
+ self.workspace_scroll = self.workspace_scroll.saturating_add(1);
+ }
+ }
+
+ pub(super) 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),
+ })
+ }
+ }
+
+ pub(super) 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,
+ ))
+ }
+
+ pub(super) 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);
+ }
+
+ pub(super) 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() {
+ return Rect::default();
+ }
+ let y = ws_area.y + ws_area.height.saturating_sub(1);
+ Rect::new(ws_area.x, y, ws_area.width, 1)
+ }
+
+ pub(crate) fn sidebar_new_button_rect(&self) -> Rect {
+ let footer = self.sidebar_footer_rect();
+ let width = 5u16.min(footer.width.max(1));
+ Rect::new(footer.x, footer.y, width, footer.height)
+ }
+
+ pub(crate) fn global_launcher_rect(&self) -> Rect {
+ let footer = self.sidebar_footer_rect();
+ let width = if self.update_available.is_some() {
+ 8
+ } else {
+ 6
+ }
+ .min(footer.width.max(1));
+ let x = footer.x + footer.width.saturating_sub(width);
+ Rect::new(x, footer.y, width, footer.height)
+ }
+
+ pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> {
+ let mut labels = vec!["settings", "keybinds", "reload keybinds"];
+ if self.update_available.is_some() {
+ labels.push("update ready");
+ } else if self.latest_release_notes_available {
+ labels.push("what's new");
+ }
+ labels.push(if self.quit_detaches { "detach" } else { "quit" });
+ labels
+ }
+
+ pub(crate) fn global_menu_rect(&self) -> Rect {
+ let screen = self.screen_rect();
+ let launcher = self.global_launcher_rect();
+ let labels = self.global_menu_labels();
+ let content_width = labels
+ .iter()
+ .map(|label| {
+ let extra = if *label == "update ready" { 2 } else { 0 };
+ label.chars().count() as u16 + extra
+ })
+ .max()
+ .unwrap_or(8)
+ .saturating_add(2);
+ let menu_w = content_width.saturating_add(2).min(screen.width.max(1));
+ let menu_h = (labels.len() as u16 + 2).min(screen.height.max(1));
+ let max_x = screen.x + screen.width.saturating_sub(menu_w);
+ let desired_x = launcher.x + launcher.width.saturating_sub(menu_w);
+ let x = desired_x.min(max_x);
+ let y = launcher.y.saturating_sub(menu_h);
+ Rect::new(x, y, menu_w, menu_h)
+ }
+
+ pub(super) fn on_sidebar_divider(&self, col: u16, row: u16) -> bool {
+ if self.sidebar_collapsed {
+ return false;
+ }
+ let sidebar = self.view.sidebar_rect;
+ sidebar.width > 0
+ && col == sidebar.x + sidebar.width.saturating_sub(1)
+ && row >= sidebar.y
+ && row < sidebar.y + sidebar.height
+ }
+
+ pub(super) fn on_collapsed_sidebar_toggle(&self, col: u16, row: u16) -> bool {
+ if !self.sidebar_collapsed {
+ return false;
+ }
+ let rect = crate::ui::collapsed_sidebar_toggle_rect(self.view.sidebar_rect);
+ rect.width > 0
+ && col >= rect.x
+ && col < rect.x + rect.width
+ && row >= rect.y
+ && row < rect.y + rect.height
+ }
+
+ pub(super) fn set_manual_sidebar_width(&mut self, divider_col: u16) {
+ let sidebar = self.view.sidebar_rect;
+ let width = divider_col.saturating_sub(sidebar.x).saturating_add(1);
+ self.sidebar_width =
+ width.clamp(crate::ui::MIN_SIDEBAR_WIDTH, crate::ui::MAX_SIDEBAR_WIDTH);
+ self.mark_session_dirty();
+ }
+
+ pub(super) 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
+ }
+
+ pub(super) 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);
+ self.mark_session_dirty();
+ }
+
+ pub(super) fn workspace_at_row(&self, row: u16) -> Option<usize> {
+ let footer = self.sidebar_footer_rect();
+ if footer == Rect::default() {
+ return None;
+ }
+
+ let cards = if self.view.workspace_card_areas.is_empty() {
+ crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect)
+ } else {
+ self.view.workspace_card_areas.clone()
+ };
+
+ cards.iter().find_map(|card| {
+ (row >= card.rect.y && row < card.rect.y + card.rect.height).then_some(card.ws_idx)
+ })
+ }
+
+ pub(super) fn collapsed_workspace_at_row(&self, row: u16) -> Option<usize> {
+ if !self.sidebar_collapsed {
+ return None;
+ }
+
+ let (ws_area, _, _) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect);
+ if ws_area == Rect::default() || row < ws_area.y || row >= ws_area.y + ws_area.height {
+ return None;
+ }
+
+ let idx = (row - ws_area.y) as usize;
+ (idx < self.workspaces.len()).then_some(idx)
+ }
+
+ fn collapsed_detail_workspace_idx(&self) -> Option<usize> {
+ if matches!(
+ self.mode,
+ Mode::Navigate
+ | Mode::RenameWorkspace
+ | Mode::Resize
+ | Mode::ConfirmClose
+ | Mode::ContextMenu
+ | Mode::Settings
+ | Mode::GlobalMenu
+ | Mode::KeybindHelp
+ ) {
+ Some(self.selected)
+ } else {
+ self.active
+ }
+ }
+
+ pub(super) fn collapsed_agent_detail_target_at(
+ &self,
+ row: u16,
+ ) -> Option<(usize, usize, crate::layout::PaneId)> {
+ if !self.sidebar_collapsed {
+ return None;
+ }
+
+ let (_, _, detail_area) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect);
+ let detail_content_area = Rect::new(
+ detail_area.x,
+ detail_area.y,
+ detail_area.width,
+ detail_area.height.saturating_sub(1),
+ );
+ if detail_content_area == Rect::default()
+ || row < detail_content_area.y
+ || row >= detail_content_area.y + detail_content_area.height
+ {
+ return None;
+ }
+
+ let ws_idx = self.collapsed_detail_workspace_idx()?;
+ let ws = self.workspaces.get(ws_idx)?;
+ let detail_idx = (row - detail_content_area.y) as usize;
+ let details = ws.pane_details();
+ let detail = details.get(detail_idx)?;
+ Some((ws_idx, detail.tab_idx, detail.pane_id))
+ }
+
+ pub(super) fn workspace_drop_index_at_row(&self, row: u16) -> Option<usize> {
+ let area = self.workspace_list_rect();
+ let footer = self.sidebar_footer_rect();
+ if area == Rect::default() || row < area.y || row >= footer.y {
+ return None;
+ }
+
+ let cards = if self.view.workspace_card_areas.is_empty() {
+ crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect)
+ } else {
+ self.view.workspace_card_areas.clone()
+ };
+ if cards.is_empty() {
+ return Some(0);
+ }
+
+ let mut insert_indices = Vec::with_capacity(cards.len() + 1);
+ insert_indices.push(cards[0].ws_idx);
+ insert_indices.extend(cards.iter().skip(1).map(|card| card.ws_idx));
+ insert_indices.push(cards.last().map(|card| card.ws_idx + 1).unwrap_or(0));
+
+ let mut best: Option<(usize, u16)> = None;
+ for insert_idx in insert_indices {
+ let Some(slot_row) = crate::ui::workspace_drop_indicator_row(&cards, area, insert_idx)
+ else {
+ continue;
+ };
+ let distance = row.abs_diff(slot_row);
+ match best {
+ Some((best_idx, best_distance))
+ if distance > best_distance
+ || (distance == best_distance && insert_idx < best_idx) => {}
+ _ => best = Some((insert_idx, distance)),
+ }
+ }
+
+ best.map(|(insert_idx, _)| insert_idx)
+ }
+
+ pub(super) fn on_agent_panel_scope_toggle(&self, col: u16, row: u16) -> bool {
+ if self.sidebar_collapsed {
+ return false;
+ }
+
+ 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
+ && col < rect.x + rect.width
+ && row >= rect.y
+ && row < rect.y + rect.height
+ }
+
+ pub(super) fn agent_detail_target_at(
+ &self,
+ row: u16,
+ ) -> Option<(usize, usize, crate::layout::PaneId)> {
+ if self.sidebar_collapsed {
+ return None;
+ }
+
+ 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 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);
+ }
+ }
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ use crossterm::event::{MouseButton, MouseEventKind};
+ use ratatui::layout::Rect;
+
+ use super::super::{app_for_mouse_test, capture_snapshot, mouse, unique_temp_path};
+ use crate::{
+ app::state::{AgentPanelScope, DragTarget, Mode},
+ detect::Agent,
+ workspace::Workspace,
+ };
+
+ #[test]
+ fn clicking_launcher_opens_global_menu() {
+ let mut app = app_for_mouse_test();
+ let rect = app.state.global_launcher_rect();
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ rect.x + rect.width.saturating_sub(1),
+ rect.y,
+ ));
+
+ assert_eq!(app.state.mode, Mode::GlobalMenu);
+ }
+
+ #[test]
+ fn hovering_global_menu_updates_highlight() {
+ let mut app = app_for_mouse_test();
+ let launcher = app.state.global_launcher_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ launcher.x,
+ launcher.y,
+ ));
+
+ let menu = app.state.global_menu_rect();
+ app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2));
+
+ assert_eq!(app.state.global_menu.highlighted, 1);
+ }
+
+ #[test]
+ fn clicking_keybinds_menu_item_opens_help() {
+ let mut app = app_for_mouse_test();
+ let launcher = app.state.global_launcher_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ launcher.x,
+ launcher.y,
+ ));
+
+ let menu = app.state.global_menu_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ menu.x + 2,
+ menu.y + 2,
+ ));
+
+ assert_eq!(app.state.mode, Mode::KeybindHelp);
+ }
+
+ #[test]
+ fn clicking_settings_menu_item_opens_settings() {
+ let mut app = app_for_mouse_test();
+ let launcher = app.state.global_launcher_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ launcher.x,
+ launcher.y,
+ ));
+
+ let menu = app.state.global_menu_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ menu.x + 2,
+ menu.y + 1,
+ ));
+
+ assert_eq!(app.state.mode, Mode::Settings);
+ }
+
+ #[test]
+ fn clicking_reload_keybinds_menu_item_requests_reload() {
+ let mut app = app_for_mouse_test();
+ let launcher = app.state.global_launcher_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ launcher.x,
+ launcher.y,
+ ));
+
+ let menu = app.state.global_menu_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ menu.x + 2,
+ menu.y + 3,
+ ));
+
+ assert!(app.state.request_reload_keybinds);
+ assert_eq!(app.state.mode, Mode::Navigate);
+ }
+
+ #[test]
+ fn update_pending_menu_surfaces_update_ready_entry() {
+ let mut app = app_for_mouse_test();
+ app.state.update_available = Some("0.3.2".into());
+ app.state.latest_release_notes_available = true;
+
+ let launcher = app.state.global_launcher_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ launcher.x,
+ launcher.y,
+ ));
+
+ assert_eq!(
+ app.state.global_menu_labels(),
+ vec![
+ "settings",
+ "keybinds",
+ "reload keybinds",
+ "update ready",
+ "quit"
+ ]
+ );
+ assert!(!app.state.should_quit);
+ }
+
+ #[test]
+ fn persistence_mode_menu_surfaces_detach_action() {
+ let mut app = app_for_mouse_test();
+ app.state.quit_detaches = true;
+
+ let launcher = app.state.global_launcher_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ launcher.x,
+ launcher.y,
+ ));
+
+ assert_eq!(
+ app.state.global_menu_labels(),
+ vec!["settings", "keybinds", "reload keybinds", "detach"]
+ );
+
+ let menu = app.state.global_menu_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ menu.x + 2,
+ menu.y + 4,
+ ));
+
+ assert!(app.state.detach_requested);
+ assert!(!app.state.should_quit);
+ }
+
+ #[test]
+ fn whats_new_remains_in_menu_for_latest_installed_release_notes() {
+ let mut app = app_for_mouse_test();
+ app.state.latest_release_notes_available = true;
+
+ assert_eq!(
+ app.state.global_menu_labels(),
+ vec![
+ "settings",
+ "keybinds",
+ "reload keybinds",
+ "what's new",
+ "quit"
+ ]
+ );
+ }
+
+ #[test]
+ fn clicking_agent_detail_row_switches_to_correct_tab_and_pane() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ ws.tabs[0].set_custom_name("main".into());
+ 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);
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 16));
+
+ assert_eq!(app.state.workspaces[0].active_tab, 1);
+ assert_eq!(
+ app.state.workspaces[0].tabs[1].layout.focused(),
+ second_pane
+ );
+ assert_eq!(app.state.mode, Mode::Terminal);
+ let snapshot = capture_snapshot(&app.state);
+ assert_eq!(snapshot.workspaces[0].active_tab, second_tab);
+ assert_eq!(
+ snapshot.workspaces[0].tabs[second_tab].focused,
+ Some(second_pane.raw())
+ );
+ }
+
+ #[test]
+ fn clicking_agent_panel_toggle_switches_scope() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ 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,
+ 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),
+ toggle.x,
+ toggle.y,
+ ));
+
+ assert_eq!(app.state.agent_panel_scope, AgentPanelScope::AllWorkspaces);
+ assert_eq!(app.state.agent_panel_scroll, 0);
+ let snapshot = capture_snapshot(&app.state);
+ assert_eq!(snapshot.agent_panel_scope, AgentPanelScope::AllWorkspaces);
+ }
+
+ #[test]
+ fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() {
+ let mut app = app_for_mouse_test();
+ let mut first = Workspace::test_new("one");
+ let first_pane = first.tabs[0].root_pane;
+ first.tabs[0]
+ .panes
+ .get_mut(&first_pane)
+ .unwrap()
+ .detected_agent = Some(Agent::Pi);
+
+ let mut second = Workspace::test_new("two");
+ let second_pane = second.tabs[0].root_pane;
+ second.tabs[0]
+ .panes
+ .get_mut(&second_pane)
+ .unwrap()
+ .detected_agent = Some(Agent::Claude);
+
+ app.state.workspaces = vec![first, second];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ 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,
+ app.state.sidebar_section_split,
+ );
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ detail_area.x + 2,
+ detail_area.y + 6,
+ ));
+
+ assert_eq!(app.state.active, Some(1));
+ assert_eq!(app.state.selected, 1);
+ assert_eq!(app.state.workspaces[1].active_tab, 0);
+ assert_eq!(
+ app.state.workspaces[1].tabs[0].layout.focused(),
+ second_pane
+ );
+ }
+
+ #[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");
+ 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);
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.sidebar_collapsed = true;
+ app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20);
+ app.state.view.terminal_area = Rect::new(4, 0, 80, 20);
+
+ let (_, _, detail_area) =
+ crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect);
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ detail_area.x,
+ detail_area.y + 1,
+ ));
+
+ assert_eq!(app.state.workspaces[0].active_tab, 1);
+ assert_eq!(
+ app.state.workspaces[0].tabs[1].layout.focused(),
+ second_pane
+ );
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn clicking_collapsed_sidebar_toggle_expands_sidebar() {
+ let mut app = app_for_mouse_test();
+ app.state.sidebar_collapsed = true;
+ app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20);
+ app.state.view.terminal_area = Rect::new(4, 0, 80, 20);
+
+ let toggle = crate::ui::collapsed_sidebar_toggle_rect(app.state.view.sidebar_rect);
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ toggle.x,
+ toggle.y,
+ ));
+
+ assert!(!app.state.sidebar_collapsed);
+ }
+
+ #[test]
+ fn clicking_workspace_switches_on_mouse_up() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let target_row = app.state.view.workspace_card_areas[1].rect.y;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ 2,
+ target_row,
+ ));
+ assert_eq!(app.state.active, Some(0));
+ assert!(app.state.workspace_press.is_some());
+
+ app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row));
+ assert_eq!(app.state.active, Some(1));
+ assert_eq!(app.state.selected, 1);
+ assert!(app.state.workspace_press.is_none());
+ let snapshot = capture_snapshot(&app.state);
+ assert_eq!(snapshot.active, Some(1));
+ assert_eq!(snapshot.selected, 1);
+ }
+
+ #[test]
+ fn dragging_workspace_reorders_without_changing_identity() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![
+ Workspace::test_new("a"),
+ Workspace::test_new("b"),
+ Workspace::test_new("c"),
+ ];
+ let active_id = app.state.workspaces[1].id.clone();
+ let selected_id = app.state.workspaces[2].id.clone();
+ app.state.active = Some(1);
+ app.state.selected = 2;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let source_row = app.state.view.workspace_card_areas[1].rect.y;
+ let target_row = crate::ui::workspace_drop_indicator_row(
+ &app.state.view.workspace_card_areas,
+ app.state.workspace_list_rect(),
+ 0,
+ )
+ .unwrap();
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ 2,
+ source_row,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ 2,
+ target_row,
+ ));
+ assert!(matches!(
+ app.state.drag.as_ref().map(|drag| &drag.target),
+ Some(DragTarget::WorkspaceReorder {
+ source_ws_idx: 1,
+ insert_idx: Some(0),
+ })
+ ));
+ app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row));
+
+ let names: Vec<_> = app
+ .state
+ .workspaces
+ .iter()
+ .map(|ws| ws.display_name())
+ .collect();
+ assert_eq!(names, vec!["b", "a", "c"]);
+ assert_eq!(app.state.active, Some(0));
+ assert_eq!(app.state.selected, 2);
+ assert_eq!(app.state.workspaces[0].id, active_id);
+ assert_eq!(app.state.workspaces[2].id, selected_id);
+ let snapshot = capture_snapshot(&app.state);
+ let captured_names: Vec<_> = snapshot
+ .workspaces
+ .iter()
+ .map(|ws| ws.custom_name.clone().unwrap())
+ .collect();
+ assert_eq!(captured_names, vec!["b", "a", "c"]);
+ }
+
+ #[test]
+ fn clicking_tab_scroll_button_reveals_hidden_tabs_without_renaming() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ ws.test_add_tab(Some("logs"));
+ ws.test_add_tab(Some("review"));
+ ws.test_add_tab(Some("ops"));
+ ws.test_add_tab(Some("notes"));
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20));
+
+ let right = app.state.view.tab_scroll_right_hit_area;
+ assert!(right.width > 0);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ right.x + 1,
+ right.y,
+ ));
+
+ assert_eq!(app.state.tab_scroll, 1);
+ assert!(!app.state.tab_scroll_follow_active);
+ assert_eq!(app.state.workspaces[0].active_tab, 0);
+ assert_eq!(app.state.view.tab_hit_areas[0].width, 0);
+ assert!(app.state.workspaces[0].tabs[0].custom_name.is_none());
+ assert_eq!(
+ app.state.workspaces[0].tabs[1].custom_name.as_deref(),
+ Some("logs")
+ );
+ }
+
+ #[test]
+ fn clicking_last_visible_tab_at_right_edge_does_not_overscroll() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ for name in [
+ "one", "two", "three", "four", "five", "six", "seven", "eight",
+ ] {
+ ws.test_add_tab(Some(name));
+ }
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.tab_scroll = usize::MAX;
+ app.state.tab_scroll_follow_active = false;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20));
+
+ let last_idx = app.state.workspaces[0].tabs.len() - 1;
+ let target = app.state.view.tab_hit_areas[last_idx];
+ let clamped_scroll = app.state.tab_scroll;
+ assert!(target.width > 0, "last tab should already be visible");
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ target.x + 1,
+ target.y,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Up(MouseButton::Left),
+ target.x + 1,
+ target.y,
+ ));
+
+ assert_eq!(app.state.workspaces[0].active_tab, last_idx);
+ assert_eq!(app.state.tab_scroll, clamped_scroll);
+ assert!(app.state.view.tab_hit_areas[last_idx].width > 0);
+ }
+
+ #[test]
+ fn dragging_tab_reorders_auto_and_custom_names_without_materializing_numbers() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ ws.test_add_tab(Some("foo"));
+ ws.test_add_tab(None);
+ let moved_root = ws.tabs[0].root_pane;
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+
+ let source = app.state.view.tab_hit_areas[0];
+ let last = app.state.view.tab_hit_areas[2];
+ let drop_col = last.x + last.width;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ source.x + 1,
+ source.y,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ drop_col,
+ source.y,
+ ));
+ assert!(matches!(
+ app.state.drag.as_ref().map(|drag| &drag.target),
+ Some(DragTarget::TabReorder {
+ ws_idx: 0,
+ source_tab_idx: 0,
+ insert_idx: Some(3),
+ })
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Up(MouseButton::Left),
+ drop_col,
+ source.y,
+ ));
+
+ let labels: Vec<_> = app.state.workspaces[0]
+ .tabs
+ .iter()
+ .map(|tab| tab.display_name())
+ .collect();
+ assert_eq!(labels, vec!["foo", "2", "3"]);
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].custom_name.as_deref(),
+ Some("foo")
+ );
+ assert!(app.state.workspaces[0].tabs[1].custom_name.is_none());
+ assert!(app.state.workspaces[0].tabs[2].custom_name.is_none());
+ assert_eq!(app.state.workspaces[0].tabs[2].root_pane, moved_root);
+ assert_eq!(app.state.workspaces[0].active_tab, 2);
+ }
+
+ fn temp_git_repo(branch: &str) -> std::path::PathBuf {
+ let repo = unique_temp_path("sidebar-drop-slot-repo");
+ fs::create_dir_all(repo.join(".git")).unwrap();
+ fs::write(
+ repo.join(".git/HEAD"),
+ format!("ref: refs/heads/{branch}\n"),
+ )
+ .unwrap();
+ repo
+ }
+
+ #[test]
+ fn top_drop_slot_is_distinct_from_gap_below_first_workspace() {
+ let mut app = app_for_mouse_test();
+ let first_repo = temp_git_repo("main");
+ let second_repo = temp_git_repo("main");
+
+ let mut first = Workspace::test_new("a");
+ let first_root = first.tabs[0].root_pane;
+ first.identity_cwd = first_repo.clone();
+ first.tabs[0]
+ .pane_cwds
+ .insert(first_root, first_repo.clone());
+
+ let mut second = Workspace::test_new("b");
+ let second_root = second.tabs[0].root_pane;
+ second.identity_cwd = second_repo.clone();
+ second.tabs[0]
+ .pane_cwds
+ .insert(second_root, second_repo.clone());
+
+ app.state.workspaces = vec![first, second];
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+
+ assert_eq!(app.state.workspace_drop_index_at_row(0), Some(0));
+ assert_eq!(app.state.workspace_drop_index_at_row(1), Some(0));
+ assert_eq!(app.state.workspace_drop_index_at_row(2), Some(0));
+ assert_eq!(app.state.workspace_drop_index_at_row(3), Some(1));
+
+ let _ = fs::remove_dir_all(first_repo);
+ let _ = fs::remove_dir_all(second_repo);
+ }
+
+ #[test]
+ fn bottom_drop_slot_stays_below_last_workspace_not_footer() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![
+ Workspace::test_new("a"),
+ Workspace::test_new("b"),
+ Workspace::test_new("c"),
+ ];
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+
+ let cards = &app.state.view.workspace_card_areas;
+ let bottom_slot = crate::ui::workspace_drop_indicator_row(
+ cards,
+ app.state.workspace_list_rect(),
+ cards.len(),
+ )
+ .unwrap();
+
+ let last = cards.last().unwrap().rect;
+ assert_eq!(bottom_slot, last.y + last.height);
+ assert!(bottom_slot < app.state.sidebar_footer_rect().y.saturating_sub(1));
+ }
+
+ #[test]
+ fn dragging_sidebar_divider_sets_manual_width() {
+ let mut app = app_for_mouse_test();
+
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
+ app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 30, 5));
+
+ assert_eq!(app.state.sidebar_width, 31);
+ let snapshot = capture_snapshot(&app.state);
+ assert_eq!(snapshot.sidebar_width, Some(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);
+ let snapshot = capture_snapshot(&app.state);
+ assert_eq!(
+ snapshot.sidebar_section_split,
+ Some(app.state.sidebar_section_split)
+ );
+ }
+
+ #[test]
+ fn double_clicking_sidebar_divider_resets_default_width() {
+ let mut app = app_for_mouse_test();
+ app.state.default_sidebar_width = 26;
+ app.state.sidebar_width = 30;
+
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
+ app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 25, 5));
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
+
+ assert_eq!(app.state.sidebar_width, 26);
+ assert!(app.state.drag.is_none());
+ let snapshot = capture_snapshot(&app.state);
+ assert_eq!(snapshot.sidebar_width, Some(26));
+ }
+}
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
new file mode 100644
index 00000000..910a49bd
--- /dev/null
+++ b/src/app/input/terminal.rs
@@ -0,0 +1,440 @@
+use bytes::Bytes;
+use crossterm::event::KeyCode;
+use tracing::{debug, warn};
+
+use crate::{
+ app::{App, Mode},
+ input::TerminalKey,
+};
+
+struct PreparedPaneInput {
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ bytes: Bytes,
+}
+
+fn is_modifier_only_key(code: &KeyCode) -> bool {
+ matches!(code, KeyCode::Modifier(_))
+}
+
+impl App {
+ pub(crate) fn handle_terminal_key_headless(&mut self, key: TerminalKey) {
+ let Some(input) = self.prepare_terminal_key_forward(key) else {
+ return;
+ };
+ if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
+ let _ = runtime.try_send_bytes(input.bytes);
+ }
+ }
+
+ fn prepare_terminal_key_forward(&mut self, key: TerminalKey) -> Option<PreparedPaneInput> {
+ self.state.clear_selection();
+ self.state.update_dismissed = true;
+
+ let key_event = key.as_key_event();
+
+ if let Some(action) = super::terminal_direct_navigation_action(&self.state, &key_event) {
+ debug!(
+ code = ?key_event.code,
+ modifiers = ?key_event.modifiers,
+ kind = ?key_event.kind,
+ action = ?action,
+ "intercepted terminal direct navigation key before forwarding to pane"
+ );
+ super::navigate::execute_navigate_action(&mut self.state, action);
+ return None;
+ }
+
+ if self.state.is_prefix(&key_event) {
+ self.state.mode = Mode::Navigate;
+ return None;
+ }
+
+ if is_modifier_only_key(&key_event.code) {
+ debug!(
+ code = ?key_event.code,
+ modifiers = ?key_event.modifiers,
+ kind = ?key_event.kind,
+ "dropping modifier-only terminal key event instead of forwarding it to pane"
+ );
+ return None;
+ }
+
+ let ws_idx = self.state.active?;
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let pane_id = ws.focused_pane_id()?;
+ let rt = ws.runtimes.get(&pane_id)?;
+ rt.scroll_reset();
+ let protocol = rt.keyboard_protocol();
+ let bytes = rt.encode_terminal_key(key);
+
+ if matches!(key_event.code, KeyCode::Esc)
+ || key_event
+ .modifiers
+ .contains(crossterm::event::KeyModifiers::ALT)
+ {
+ debug!(
+ code = ?key_event.code,
+ modifiers = ?key_event.modifiers,
+ kind = ?key_event.kind,
+ protocol = ?protocol,
+ encoded = ?bytes,
+ "forwarding potentially-ambiguous terminal key to pane"
+ );
+ }
+
+ if bytes.is_empty() {
+ if key.kind != crossterm::event::KeyEventKind::Release
+ && !matches!(
+ key.code,
+ KeyCode::CapsLock
+ | KeyCode::ScrollLock
+ | KeyCode::NumLock
+ | KeyCode::PrintScreen
+ | KeyCode::Pause
+ | KeyCode::Menu
+ | KeyCode::KeypadBegin
+ | KeyCode::Media(_)
+ | KeyCode::Modifier(_)
+ )
+ {
+ warn!(code = ?key_event.code, mods = ?key_event.modifiers, state = ?key_event.state, "key produced empty encoding");
+ }
+ return None;
+ }
+
+ Some(PreparedPaneInput {
+ ws_idx,
+ pane_id,
+ bytes: Bytes::from(bytes),
+ })
+ }
+
+ pub(super) async fn handle_terminal_key(&mut self, key: TerminalKey) {
+ let Some(input) = self.prepare_terminal_key_forward(key) else {
+ return;
+ };
+ if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
+ let _ = runtime.send_bytes(input.bytes).await;
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
+ use ratatui::layout::Rect;
+
+ use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes};
+ use super::*;
+ use crate::{config::Config, workspace::Workspace};
+
+ #[tokio::test]
+ async fn dragging_selection_above_pane_autoscrolls_and_extends_into_scrollback() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let pane_id = ws.tabs[0].root_pane;
+ let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
+ let info = pane_infos[0].clone();
+ ws.tabs[0].runtimes.insert(
+ pane_id,
+ crate::pane::PaneRuntime::test_with_scrollback_bytes(
+ info.inner_rect.width,
+ info.inner_rect.height,
+ 16 * 1024,
+ &numbered_lines_bytes(64),
+ ),
+ );
+
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.view.pane_infos = pane_infos;
+
+ let start_metrics = app.state.workspaces[0]
+ .runtime(pane_id)
+ .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .expect("initial scroll metrics");
+ let start_row = info.inner_rect.y;
+ let start_col = info.inner_rect.x + 2;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ start_col,
+ start_row,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ start_col,
+ info.inner_rect.y.saturating_sub(1),
+ ));
+
+ let end_metrics = app.state.workspaces[0]
+ .runtime(pane_id)
+ .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .expect("scroll metrics after drag");
+ assert_eq!(
+ end_metrics.offset_from_bottom,
+ start_metrics.offset_from_bottom + 3
+ );
+
+ let selection = app.state.selection.as_ref().expect("selection after drag");
+ assert!(selection.is_visible());
+ assert_eq!(
+ selection.ordered_cells(),
+ (
+ (
+ (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32,
+ 2,
+ ),
+ (start_metrics.max_offset_from_bottom as u32, 2),
+ )
+ );
+ }
+
+ #[tokio::test]
+ async fn releasing_dragged_selection_clears_highlight_after_copy() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let pane_id = ws.tabs[0].root_pane;
+ let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
+ let info = pane_infos[0].clone();
+ ws.tabs[0].runtimes.insert(
+ pane_id,
+ crate::pane::PaneRuntime::test_with_scrollback_bytes(
+ info.inner_rect.width,
+ info.inner_rect.height,
+ 16 * 1024,
+ &numbered_lines_bytes(64),
+ ),
+ );
+
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.view.pane_infos = pane_infos;
+
+ let row = info.inner_rect.y;
+ let start_col = info.inner_rect.x + 1;
+ let end_col = info.inner_rect.x + 4;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ start_col,
+ row,
+ ));
+ app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
+ assert!(app.state.selection.is_some());
+
+ app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
+
+ assert!(app.state.selection.is_none());
+ }
+
+ #[tokio::test]
+ async fn wheel_scroll_keeps_in_progress_selection_and_extends_it() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let pane_id = ws.tabs[0].root_pane;
+ let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
+ let info = pane_infos[0].clone();
+ ws.tabs[0].runtimes.insert(
+ pane_id,
+ crate::pane::PaneRuntime::test_with_scrollback_bytes(
+ info.inner_rect.width,
+ info.inner_rect.height,
+ 16 * 1024,
+ &numbered_lines_bytes(64),
+ ),
+ );
+
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.view.pane_infos = pane_infos;
+
+ let start_metrics = app.state.workspaces[0]
+ .runtime(pane_id)
+ .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .expect("initial scroll metrics");
+ let top_row = info.inner_rect.y;
+ let col = info.inner_rect.x + 2;
+
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, top_row));
+ app.handle_mouse(mouse(MouseEventKind::ScrollUp, col, top_row));
+
+ let end_metrics = app.state.workspaces[0]
+ .runtime(pane_id)
+ .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .expect("scroll metrics after wheel");
+ assert_eq!(
+ end_metrics.offset_from_bottom,
+ start_metrics.offset_from_bottom + 3
+ );
+
+ let selection = app.state.selection.as_ref().expect("selection after wheel");
+ assert!(selection.is_visible());
+ assert_eq!(
+ selection.ordered_cells(),
+ (
+ (
+ (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32,
+ 2,
+ ),
+ (start_metrics.max_offset_from_bottom as u32, 2),
+ )
+ );
+ }
+
+ #[tokio::test]
+ async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_left_button() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let first_pane = ws.tabs[0].root_pane;
+ let second_pane = ws.test_split(ratatui::layout::Direction::Vertical);
+
+ let terminal_area = Rect::new(26, 2, 80, 18);
+ let pane_infos = ws.tabs[0].layout.panes(terminal_area);
+ let first_info = pane_infos
+ .iter()
+ .find(|p| p.id == first_pane)
+ .unwrap()
+ .clone();
+ let second_info = pane_infos
+ .iter()
+ .find(|p| p.id == second_pane)
+ .unwrap()
+ .clone();
+
+ ws.tabs[0].runtimes.insert(
+ first_pane,
+ crate::pane::PaneRuntime::test_with_screen_bytes(
+ first_info.inner_rect.width.max(1),
+ first_info.inner_rect.height.max(1),
+ b"",
+ ),
+ );
+ ws.tabs[0].runtimes.insert(
+ second_pane,
+ crate::pane::PaneRuntime::test_with_screen_bytes(
+ second_info.inner_rect.width.max(1),
+ second_info.inner_rect.height.max(1),
+ b"\x1b[?1002h",
+ ),
+ );
+
+ ws.tabs[0].layout.focus_pane(first_pane);
+
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.view.pane_infos = pane_infos;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ second_info.inner_rect.x + 2,
+ second_info.inner_rect.y + 2,
+ ));
+
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].layout.focused(),
+ second_pane
+ );
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[tokio::test]
+ async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_right_button() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let first_pane = ws.tabs[0].root_pane;
+ let second_pane = ws.test_split(ratatui::layout::Direction::Vertical);
+
+ let terminal_area = Rect::new(26, 2, 80, 18);
+ let pane_infos = ws.tabs[0].layout.panes(terminal_area);
+ let first_info = pane_infos
+ .iter()
+ .find(|p| p.id == first_pane)
+ .unwrap()
+ .clone();
+ let second_info = pane_infos
+ .iter()
+ .find(|p| p.id == second_pane)
+ .unwrap()
+ .clone();
+
+ ws.tabs[0].runtimes.insert(
+ first_pane,
+ crate::pane::PaneRuntime::test_with_screen_bytes(
+ first_info.inner_rect.width.max(1),
+ first_info.inner_rect.height.max(1),
+ b"",
+ ),
+ );
+ ws.tabs[0].runtimes.insert(
+ second_pane,
+ crate::pane::PaneRuntime::test_with_screen_bytes(
+ second_info.inner_rect.width.max(1),
+ second_info.inner_rect.height.max(1),
+ b"\x1b[?1002h",
+ ),
+ );
+
+ ws.tabs[0].layout.focus_pane(first_pane);
+
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.view.pane_infos = pane_infos;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Right),
+ second_info.inner_rect.x + 2,
+ second_info.inner_rect.y + 2,
+ ));
+
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].layout.focused(),
+ second_pane
+ );
+ assert_eq!(app.state.mode, Mode::ContextMenu);
+ assert!(app.state.context_menu.is_some());
+ }
+
+ #[tokio::test]
+ async fn terminal_direct_focus_pane_shortcut_switches_focus_without_leaving_terminal_mode() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.view.pane_infos = app.state.workspaces[0]
+ .active_tab()
+ .unwrap()
+ .layout
+ .panes(Rect::new(0, 0, 80, 24));
+ let focused_before = app.state.workspaces[0].layout.focused();
+ app.state.keybinds.focus_pane_left = Some((KeyCode::Char('h'), KeyModifiers::ALT));
+ app.state.keybinds.focus_pane_left_label = Some("alt+h".into());
+
+ app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT))
+ .await;
+
+ assert_ne!(app.state.workspaces[0].layout.focused(), focused_before);
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+}