diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 8dc631d8..1b756d1e 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Added a session navigator at `prefix+g` with a searchable workspace/tab/pane tree, agent state filters, mouse switching, and keyboard navigation. (#157) +- Added a configurable `last_pane` keybinding action for tmux-style back-and-forth navigation to the last focused pane across workspaces and tabs. It is unset by default. (#287) - Added scrollback support to direct agent terminal attaches. Mouse wheel and plain PageUp/PageDown now scroll the attached terminal viewport, while terminal apps that request mouse or alternate-scroll input still receive those events. The client/server protocol is now version 11. - Added `ui.redraw_on_focus_gained` to keep the existing full redraw on outer-terminal focus gain by default while allowing users to opt out of the visible refresh. (#282) - Added `--handoff` for `herdr update` and `herdr --remote` to opt into live server handoff for supported running servers. Plain update and remote attach use the normal restart/stop flow by default. diff --git a/docs/next/README.md b/docs/next/README.md index f91729fe..69af1eeb 100644 --- a/docs/next/README.md +++ b/docs/next/README.md @@ -243,6 +243,8 @@ resize mode: `h`/`l` resize width, `j`/`k` resize height, `esc` exit. session navigator opens a searchable workspace, tab, and pane tree. use `/` for text search, `b`/`w`/`i`/`d` for blocked, working, idle, and done filters, `a` or backspace to clear a state filter, and enter to switch to the highlighted row. +last-pane is available but unset by default. bind `last_pane` in `[keys]` if you want tmux-style back-and-forth navigation to the last focused pane across workspaces and tabs; for example, `last_pane = "prefix+tab"`. + custom command keybindings can launch detached shell helpers or temporary panes: ```toml diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 53bfd3f8..15c2acd7 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -128,6 +128,9 @@ focus_pane_left = "prefix+h" focus_pane_down = "prefix+j" focus_pane_up = "prefix+k" focus_pane_right = "prefix+l" +cycle_pane_next = "prefix+tab" +cycle_pane_previous = "prefix+shift+tab" +last_pane = "" split_vertical = "prefix+v" split_horizontal = "prefix+minus" close_pane = "prefix+x" @@ -142,11 +145,14 @@ Optional actions are unset by default. Bind them with `prefix+` for prefix-mode [keys] previous_workspace = "prefix+shift+left" next_workspace = "prefix+shift+right" +last_pane = "prefix+tab" open_worktree = "prefix+shift+o" remove_worktree = "prefix+alt+d" next_tab = ["prefix+n", "ctrl+alt+]"] ``` +`last_pane` switches back to the last focused pane across workspaces and tabs. It is unset by default because the tmux-style pane binding `prefix+l` is already used for pane-right focus. + Key strings accept plain keys, modifier combinations such as `ctrl+a`, `shift+n`, `alt+1`, `cmd+k`, and special keys such as `enter`, `tab`, `esc`, `left`, `right`, `up`, and `down`. Named punctuation such as `minus`, `comma`, `ampersand`, `plus`, and `backtick` is also accepted. Plain direct printable keys such as `n` are unsafe because they intercept typing; use `prefix+n` unless you intentionally want a direct binding. The `navigate_workspace_*` and `navigate_pane_*` fields are navigate-mode-only and may use plain keys such as `j` or `k`; they must not use `prefix+`, `esc`, `enter`, `tab`, `shift+tab`, `left`, `right`, or unmodified `1` through `9`. Left and right arrows are permanent aliases for pane-left and pane-right navigation. These navigate-mode shortcuts are independent from general action bindings such as `focus_pane_down = "prefix+j"`; when both use the same key, the navigate-mode shortcut wins while navigate mode is open. Alt, Cmd/Super, and punctuation with modifiers depend on your terminal and tmux settings. If you have old custom keybindings and want the new defaults, run `herdr config reset-keys`. Herdr backs up `config.toml`, removes `[keys]` and `[[keys.command]]`, and uses built-in v2 defaults after restart or `herdr server reload-config`. diff --git a/src/app/actions.rs b/src/app/actions.rs index 65142fd1..da9e538a 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -12,8 +12,8 @@ use crate::workspace::WorkspaceGitStatus; use unicode_width::UnicodeWidthChar; use super::state::{ - AppState, Mode, NavigatorRow, NavigatorStateFilter, NavigatorTarget, ToastKind, - ToastNotification, ToastTarget, ViewLayout, + AppState, Mode, NavigatorRow, NavigatorStateFilter, NavigatorTarget, PaneFocusTarget, + ToastKind, ToastNotification, ToastTarget, ViewLayout, }; fn is_background_completion_transition(prev_state: AgentState, new_state: AgentState) -> bool { @@ -104,6 +104,80 @@ pub struct PaneStateUpdate { // --------------------------------------------------------------------------- impl AppState { + pub(crate) fn current_pane_focus_target(&self) -> Option { + let ws_idx = self.active?; + let ws = self.workspaces.get(ws_idx)?; + let pane_id = ws.focused_pane_id()?; + Some(PaneFocusTarget { + workspace_id: ws.id.clone(), + pane_id, + }) + } + + fn pane_focus_target_indices(&self, target: &PaneFocusTarget) -> Option<(usize, usize)> { + let ws_idx = self + .workspaces + .iter() + .position(|ws| ws.id == target.workspace_id)?; + let tab_idx = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id)?; + Some((ws_idx, tab_idx)) + } + + pub(crate) fn record_pane_focus_change( + &mut self, + previous: Option, + ws_idx: usize, + pane_id: PaneId, + ) { + let Some(ws) = self.workspaces.get(ws_idx) else { + return; + }; + let target = PaneFocusTarget { + workspace_id: ws.id.clone(), + pane_id, + }; + if previous.as_ref() != Some(&target) { + self.previous_pane_focus = previous; + } + } + + fn record_pane_focus_after_navigation(&mut self, previous: Option) { + let current = self.current_pane_focus_target(); + if previous != current { + self.previous_pane_focus = previous; + } + } + + pub(crate) fn focus_pane_in_workspace(&mut self, ws_idx: usize, pane_id: PaneId) -> bool { + let Some(ws) = self.workspaces.get(ws_idx) else { + return false; + }; + let Some(tab_idx) = ws.find_tab_index_for_pane(pane_id) else { + return false; + }; + let previous = self.current_pane_focus_target(); + let target = PaneFocusTarget { + workspace_id: ws.id.clone(), + pane_id, + }; + if previous.as_ref() == Some(&target) { + return false; + } + + self.switch_workspace_tab(ws_idx, tab_idx); + if let Some(tab) = self + .workspaces + .get_mut(ws_idx) + .and_then(|ws| ws.tabs.get_mut(tab_idx)) + { + tab.layout.focus_pane(pane_id); + self.previous_pane_focus = previous; + self.mark_session_dirty(); + return true; + } + false + } + pub(crate) fn open_navigator(&mut self) { self.navigator.query.clear(); self.navigator.search_focused = false; @@ -403,8 +477,7 @@ impl AppState { if !tab_exists { return false; } - self.switch_workspace(ws_idx); - self.switch_tab(tab_idx); + self.switch_workspace_tab(ws_idx, tab_idx); self.mode = Mode::Terminal; true } @@ -416,19 +489,15 @@ impl AppState { if ws_idx >= self.workspaces.len() { return false; } - self.switch_workspace(ws_idx); - self.switch_tab(tab_idx); - if let Some(tab) = self + if self .workspaces - .get_mut(ws_idx) - .and_then(|ws| ws.tabs.get_mut(tab_idx)) + .get(ws_idx) + .and_then(|ws| ws.tabs.get(tab_idx)) + .is_some_and(|tab| tab.panes.contains_key(&pane_id)) { - if tab.panes.contains_key(&pane_id) { - tab.layout.focus_pane(pane_id); - self.mark_session_dirty(); - self.mode = Mode::Terminal; - return true; - } + self.focus_pane_in_workspace(ws_idx, pane_id); + self.mode = Mode::Terminal; + return true; } false } @@ -601,6 +670,7 @@ impl AppState { pub fn switch_workspace(&mut self, idx: usize) { if idx < self.workspaces.len() { + let previous_focus = self.current_pane_focus_target(); self.selection = None; self.selection_autoscroll = None; self.active = Some(idx); @@ -623,9 +693,53 @@ impl AppState { } self.tab_scroll_follow_active = true; self.refresh_tab_bar_view(); + self.record_pane_focus_after_navigation(previous_focus); } } + pub(crate) fn switch_workspace_tab(&mut self, ws_idx: usize, tab_idx: usize) -> bool { + if ws_idx >= self.workspaces.len() { + return false; + } + if self + .workspaces + .get(ws_idx) + .is_none_or(|ws| tab_idx >= ws.tabs.len()) + { + return false; + } + + let previous_focus = self.current_pane_focus_target(); + let workspace_changed = self.active != Some(ws_idx); + self.selection = None; + self.selection_autoscroll = None; + self.active = Some(ws_idx); + self.selected = ws_idx; + let workspace_id = self.workspaces[ws_idx].id.clone(); + if workspace_changed { + crate::logging::workspace_focused(&workspace_id); + } + self.mark_session_dirty(); + if workspace_changed + && matches!( + self.agent_panel_scope, + crate::app::state::AgentPanelScope::CurrentWorkspace + ) + { + self.agent_panel_scroll = 0; + } + self.ensure_workspace_visible(ws_idx); + if let Some(ws) = self.workspaces.get_mut(ws_idx) { + ws.switch_tab(tab_idx); + let tab_id = format!("{}:{}", workspace_id, tab_idx + 1); + crate::logging::tab_focused(&workspace_id, &tab_id); + } + self.tab_scroll_follow_active = true; + self.refresh_tab_bar_view(); + self.record_pane_focus_after_navigation(previous_focus); + true + } + pub(crate) fn ensure_workspace_visible(&mut self, idx: usize) { if idx >= self.workspaces.len() { return; @@ -707,6 +821,7 @@ impl AppState { pub fn switch_tab(&mut self, idx: usize) { if let Some(ws_idx) = self.active { + let previous_focus = self.current_pane_focus_target(); self.selection = None; self.selection_autoscroll = None; let Some(ws) = self.workspaces.get_mut(ws_idx) else { @@ -719,6 +834,7 @@ impl AppState { self.mark_session_dirty(); self.tab_scroll_follow_active = true; self.refresh_tab_bar_view(); + self.record_pane_focus_after_navigation(previous_focus); } } @@ -893,22 +1009,17 @@ impl AppState { return false; }; let ws_idx = target.ws_idx; - let tab_idx = target.tab_idx; let pane_id = target.pane_id; - self.switch_workspace(ws_idx); - self.switch_tab(tab_idx); - if let Some(tab) = self - .workspaces - .get_mut(ws_idx) - .and_then(|ws| ws.tabs.get_mut(tab_idx)) + if self.active == Some(ws_idx) && self.workspaces[ws_idx].focused_pane_id() == Some(pane_id) { - if tab.panes.contains_key(&pane_id) { - tab.layout.focus_pane(pane_id); - self.mark_session_dirty(); - self.ensure_agent_panel_entry_visible(idx); - return true; - } + self.ensure_agent_panel_entry_visible(idx); + return true; + } + + if self.focus_pane_in_workspace(ws_idx, pane_id) { + self.ensure_agent_panel_entry_visible(idx); + return true; } false } @@ -1124,14 +1235,7 @@ impl AppState { if let Some(focused) = panes.iter().find(|p| p.is_focused) { if let Some(target) = find_in_direction(focused, direction, &panes) { - if let Some(tab) = self - .workspaces - .get_mut(ws_idx) - .and_then(|ws| ws.active_tab_mut()) - { - tab.layout.focus_pane(target); - self.mark_session_dirty(); - } + self.focus_pane_in_workspace(ws_idx, target); } } } @@ -1155,16 +1259,45 @@ impl AppState { } pub fn cycle_pane(&mut self, reverse: bool) { - if let Some(tab) = self - .active - .and_then(|i| self.workspaces.get_mut(i)) - .and_then(|ws| ws.active_tab_mut()) - { - if reverse { - tab.layout.focus_prev(); + let Some(ws_idx) = self.active else { + return; + }; + let Some(tab) = self.workspaces.get(ws_idx).and_then(|ws| ws.active_tab()) else { + return; + }; + let ids = tab.layout.pane_ids(); + if let Some(pos) = ids.iter().position(|id| *id == tab.layout.focused()) { + let target = if reverse { + ids[(pos + ids.len() - 1) % ids.len()] } else { - tab.layout.focus_next(); - } + ids[(pos + 1) % ids.len()] + }; + self.focus_pane_in_workspace(ws_idx, target); + } + } + + pub fn last_pane(&mut self) { + let Some(target) = self.previous_pane_focus.clone() else { + return; + }; + let Some((ws_idx, tab_idx)) = self.pane_focus_target_indices(&target) else { + self.previous_pane_focus = None; + return; + }; + let current = self.current_pane_focus_target(); + if current.as_ref() == Some(&target) { + self.previous_pane_focus = None; + return; + } + + self.switch_workspace_tab(ws_idx, tab_idx); + if let Some(tab) = self + .workspaces + .get_mut(ws_idx) + .and_then(|ws| ws.tabs.get_mut(tab_idx)) + { + tab.layout.focus_pane(target.pane_id); + self.previous_pane_focus = current; self.mark_session_dirty(); } } @@ -2514,6 +2647,18 @@ mod tests { assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); } + #[test] + fn focus_agent_entry_succeeds_for_already_focused_agent() { + let mut state = app_with_workspaces(&["one"]); + let root = state.workspaces[0].tabs[0].root_pane; + state.agent_panel_scope = crate::app::state::AgentPanelScope::AllWorkspaces; + mark_agent(&mut state, 0, 0, root); + + assert!(state.focus_agent_entry(0)); + assert_eq!(state.active, Some(0)); + assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); + } + #[test] fn next_agent_cycles_only_current_scope_entries() { let mut first = Workspace::test_new("one"); @@ -2577,6 +2722,122 @@ mod tests { assert_eq!(state.selected, 2); } + #[test] + fn last_pane_toggles_to_previous_focus_in_active_tab() { + let mut state = app_with_workspaces(&["test"]); + let root = state.workspaces[0].tabs[0].root_pane; + let right = state.workspaces[0].test_split(Direction::Horizontal); + + state.focus_pane_in_workspace(0, root); + state.focus_pane_in_workspace(0, right); + state.last_pane(); + + assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); + + state.last_pane(); + + assert_eq!(state.workspaces[0].focused_pane_id(), Some(right)); + } + + #[test] + fn removing_background_pane_preserves_last_pane_history() { + let mut state = app_with_workspaces(&["test"]); + let root = state.workspaces[0].tabs[0].root_pane; + let right = state.workspaces[0].test_split(Direction::Horizontal); + let background = state.workspaces[0].test_split(Direction::Horizontal); + + state.focus_pane_in_workspace(0, root); + state.focus_pane_in_workspace(0, right); + state.workspaces[0].remove_pane(background); + state.last_pane(); + + assert_eq!(state.workspaces[0].focused_pane_id(), Some(root)); + } + + #[test] + fn last_pane_jumps_across_workspaces_and_tabs() { + let mut state = app_with_workspaces(&["one", "two"]); + let first_root = state.workspaces[0].tabs[0].root_pane; + let second_tab = state.workspaces[1].test_add_tab(Some("logs")); + let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane; + + state.focus_pane_in_workspace(0, first_root); + state.focus_pane_in_workspace(1, second_tab_root); + state.last_pane(); + + assert_eq!(state.active, Some(0)); + assert_eq!(state.workspaces[0].active_tab, 0); + assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); + + state.last_pane(); + + assert_eq!(state.active, Some(1)); + assert_eq!(state.workspaces[1].active_tab, second_tab); + assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root)); + } + + #[test] + fn last_pane_tracks_tab_and_workspace_switches() { + let mut state = app_with_workspaces(&["one", "two"]); + let first_root = state.workspaces[0].tabs[0].root_pane; + let first_second_tab = state.workspaces[0].test_add_tab(Some("logs")); + let first_second_root = state.workspaces[0].tabs[first_second_tab].root_pane; + let second_root = state.workspaces[1].tabs[0].root_pane; + + state.switch_tab(first_second_tab); + state.last_pane(); + + assert_eq!(state.active, Some(0)); + assert_eq!(state.workspaces[0].active_tab, 0); + assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); + + state.last_pane(); + + assert_eq!(state.active, Some(0)); + assert_eq!(state.workspaces[0].active_tab, first_second_tab); + assert_eq!( + state.workspaces[0].focused_pane_id(), + Some(first_second_root) + ); + + state.switch_workspace(1); + state.last_pane(); + + assert_eq!(state.active, Some(0)); + assert_eq!(state.workspaces[0].active_tab, first_second_tab); + assert_eq!( + state.workspaces[0].focused_pane_id(), + Some(first_second_root) + ); + + state.last_pane(); + + assert_eq!(state.active, Some(1)); + assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root)); + } + + #[test] + fn last_pane_tracks_cross_workspace_tab_selection() { + let mut state = app_with_workspaces(&["one", "two"]); + let first_root = state.workspaces[0].tabs[0].root_pane; + let second_first_root = state.workspaces[1].tabs[0].root_pane; + let second_tab = state.workspaces[1].test_add_tab(Some("logs")); + let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane; + + state.switch_workspace_tab(1, second_tab); + state.last_pane(); + + assert_eq!(state.active, Some(0)); + assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root)); + + state.last_pane(); + + assert_eq!(state.active, Some(1)); + assert_eq!(state.workspaces[1].active_tab, second_tab); + assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root)); + assert_ne!(second_first_root, second_tab_root); + } + #[test] fn switch_workspace_keeps_selected_visible_in_scrolled_sidebar() { let mut state = app_with_workspaces(&["a", "b", "c", "d", "e", "f", "g", "h"]); @@ -3387,7 +3648,7 @@ mod tests { } #[test] - fn close_tab_last_tab_closes_active_workspace_not_selected_workspace() { + fn close_tab_closes_active_workspace_not_selected_workspace() { let mut state = app_with_workspaces(&["selected", "active"]); let active_terminal_id = state .terminal_id_for_pane(1, state.workspaces[1].tabs[0].root_pane) @@ -3419,7 +3680,7 @@ mod tests { } #[test] - fn close_tab_last_tab_in_linked_worktree_closes_workspace_only() { + fn close_tab_in_linked_worktree_closes_workspace_only() { let mut state = app_with_workspaces(&["selected", "active"]); mark_linked_worktree(&mut state, 1); state.active = Some(1); diff --git a/src/app/agents.rs b/src/app/agents.rs index f36ba1ca..b1276a06 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -36,16 +36,8 @@ impl App { target: &str, ) -> Result { let resolved = self.resolve_terminal_target(target)?; - self.state.switch_workspace(resolved.ws_idx); - self.state.switch_tab(resolved.tab_idx); - if let Some(tab) = self - .state - .workspaces - .get_mut(resolved.ws_idx) - .and_then(|ws| ws.tabs.get_mut(resolved.tab_idx)) - { - tab.layout.focus_pane(resolved.pane_id); - } + self.state + .focus_pane_in_workspace(resolved.ws_idx, resolved.pane_id); self.state.mode = Mode::Terminal; self.agent_info(resolved.ws_idx, resolved.pane_id) .ok_or_else(|| TerminalTargetError::NotFound { @@ -359,6 +351,7 @@ impl App { focus: bool, ) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> { let (rows, cols) = self.state.estimate_pane_size(); + let previous_focus = self.state.current_pane_focus_target(); let direction = match split { SplitDirection::Right => ratatui::layout::Direction::Horizontal, SplitDirection::Down => ratatui::layout::Direction::Vertical, @@ -390,8 +383,9 @@ impl App { .terminals .insert(result.1.terminal.id.clone(), result.1.terminal); if focus { - self.state.switch_workspace(ws_idx); - self.state.switch_tab(result.0); + self.state.switch_workspace_tab(ws_idx, result.0); + self.state + .record_pane_focus_change(previous_focus, ws_idx, result.1.pane_id); self.state.mode = Mode::Terminal; } self.schedule_session_save(); diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index d5dcb132..9a31ec34 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -34,6 +34,7 @@ impl App { let default_shell = self.state.default_shell.clone(); let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; let host_terminal_theme = self.state.host_terminal_theme; + let previous_focus = self.state.current_pane_focus_target(); let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return pane_not_found(id, ¶ms.target_pane_id); }; @@ -57,8 +58,9 @@ impl App { None => return pane_not_found(id, ¶ms.target_pane_id), }; if params.focus { - self.state.switch_workspace(ws_idx); - self.state.switch_tab(target_tab_idx); + self.state.switch_workspace_tab(ws_idx, target_tab_idx); + self.state + .record_pane_focus_change(previous_focus, ws_idx, new_pane.pane_id); self.state.mode = Mode::Terminal; } self.terminal_runtimes diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index 06d59fe3..0344fcea 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -109,8 +109,7 @@ impl App { } } if focus { - self.state.switch_workspace(ws_idx); - self.state.switch_tab(tab_idx); + self.state.switch_workspace_tab(ws_idx, tab_idx); self.state.mode = Mode::Terminal; } self.schedule_session_save(); @@ -142,8 +141,7 @@ impl App { let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { return tab_not_found(id, &target.tab_id); }; - self.state.switch_workspace(ws_idx); - self.state.switch_tab(tab_idx); + self.state.switch_workspace_tab(ws_idx, tab_idx); let tab = self.tab_info(ws_idx, tab_idx).unwrap(); encode_success(id, ResponseResult::TabInfo { tab }) diff --git a/src/app/creation.rs b/src/app/creation.rs index 4d91e250..5bb14953 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -115,7 +115,7 @@ impl App { self.terminal_runtimes.insert(terminal.id.clone(), runtime); self.state.terminals.insert(terminal.id.clone(), terminal); if focus { - ws.switch_tab(idx); + self.state.switch_workspace_tab(ws_idx, idx); self.state.mode = Mode::Terminal; } let workspace_id = self.state.workspaces[ws_idx].id.clone(); diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 0f0b3568..e145e628 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -367,7 +367,11 @@ impl AppState { follow_cwd, )); - if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + let previous_focus = self.current_pane_focus_target(); + if let Some(ws_idx) = self.active { + let Some(ws) = self.workspaces.get_mut(ws_idx) else { + return; + }; if let Ok(new_pane) = ws.split_focused( direction, new_rows, @@ -381,7 +385,7 @@ impl AppState { terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime); self.terminals .insert(new_pane.terminal.id.clone(), new_pane.terminal); - ws.layout.focus_pane(new_id); + self.record_pane_focus_change(previous_focus, ws_idx, new_id); self.mark_session_dirty(); self.mode = Mode::Terminal; } diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 8adfa55b..6db1a4a9 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -455,12 +455,10 @@ impl AppState { return None; } - if let Some((ws_idx, tab_idx, pane_id)) = + 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.focus_pane_in_workspace(ws_idx, pane_id); self.mode = Mode::Terminal; } return None; @@ -550,11 +548,10 @@ impl AppState { return None; } - if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row) + 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.focus_pane_in_workspace(ws_idx, pane_id); self.mode = Mode::Terminal; return None; } @@ -1027,12 +1024,10 @@ impl AppState { } Some(crate::ui::MobileSwitcherTarget::Agent { ws_idx, - tab_idx, + tab_idx: _, pane_id, }) => { - self.switch_workspace(ws_idx); - self.switch_tab(tab_idx); - self.focus_pane(pane_id); + self.focus_pane_in_workspace(ws_idx, pane_id); self.mode = Mode::Terminal; } Some(crate::ui::MobileSwitcherTarget::Menu(action_idx)) => { @@ -1264,11 +1259,8 @@ impl AppState { } 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(); - } + if let Some(ws_idx) = self.active { + self.focus_pane_in_workspace(ws_idx, pane_id); } } @@ -1290,13 +1282,11 @@ impl AppState { else { return; }; - let Some(tab_idx) = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id) else { + let Some(_tab_idx) = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id) else { return; }; - self.switch_workspace(ws_idx); - self.switch_tab(tab_idx); - self.focus_pane(target.pane_id); + self.focus_pane_in_workspace(ws_idx, target.pane_id); self.toast = None; self.mode = Mode::Terminal; } @@ -1738,6 +1728,14 @@ mod tests { assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target_pane)); assert!(app.state.toast.is_none()); assert_eq!(app.state.mode, Mode::Terminal); + + app.state.last_pane(); + + assert_eq!(app.state.active, Some(0)); + assert_eq!( + app.state.workspaces[0].focused_pane_id(), + Some(app.state.workspaces[0].tabs[0].root_pane) + ); } #[test] diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 108fed8b..3b8435df 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -285,6 +285,7 @@ impl App { let Some(ws_idx) = self.state.active else { return Err(std::io::Error::other("no active workspace")); }; + let previous_focus_target = self.state.current_pane_focus_target(); let (rows, cols) = self.state.estimate_pane_size(); let new_rows = rows.max(4); let new_cols = cols.max(10); @@ -323,6 +324,13 @@ impl App { self.state .terminals .insert(new_pane.terminal.id.clone(), new_pane.terminal); + let new_focus_target = crate::app::state::PaneFocusTarget { + workspace_id: ws.id.clone(), + pane_id: new_pane_id, + }; + if previous_focus_target.as_ref() != Some(&new_focus_target) { + self.state.previous_pane_focus = previous_focus_target; + } ws.active_tab_mut() .expect("workspace must have an active tab") .layout @@ -499,6 +507,7 @@ pub(crate) enum NavigateAction { ToggleSidebar, CyclePaneNext, CyclePanePrevious, + LastPane, Help, Settings, ReloadConfig, @@ -589,6 +598,7 @@ fn action_for_key( (&kb.focus_pane_down, NavigateAction::FocusPaneDown), (&kb.focus_pane_up, NavigateAction::FocusPaneUp), (&kb.focus_pane_right, NavigateAction::FocusPaneRight), + (&kb.last_pane, NavigateAction::LastPane), (&kb.cycle_pane_next, NavigateAction::CyclePaneNext), (&kb.cycle_pane_previous, NavigateAction::CyclePanePrevious), (&kb.split_vertical, NavigateAction::SplitVertical), @@ -794,6 +804,10 @@ pub(super) fn execute_navigate_action_in_context( state.cycle_pane(true); leave_navigate_mode(state); } + NavigateAction::LastPane => { + state.last_pane(); + leave_navigate_mode(state); + } NavigateAction::Help => super::modal::open_keybind_help(state), NavigateAction::Settings => super::settings::open_settings(state), NavigateAction::ReloadConfig => { @@ -1453,6 +1467,40 @@ navigate_pane_right = "ctrl+l" assert_eq!(action, Some(NavigateAction::FocusPaneLeft)); } + #[test] + fn terminal_direct_last_pane_shortcut_maps_to_navigation_action() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.last_pane = crate::config::ActionKeybinds::direct("alt+l"); + + let action = terminal_direct_navigation_action( + &state, + TerminalKey::new(KeyCode::Char('l'), KeyModifiers::ALT), + ); + + assert_eq!(action, Some(NavigateAction::LastPane)); + } + + #[test] + fn prefix_tab_override_can_map_to_last_pane() { + let config: Config = toml::from_str( + r#" +[keys] +last_pane = "prefix+tab" +"#, + ) + .unwrap(); + let mut state = state_with_workspaces(&["test"]); + state.keybinds = config.keybinds(); + + let pane_action = action_for_key( + &state, + TerminalKey::new(KeyCode::Tab, KeyModifiers::empty()), + BindingDispatch::Prefix, + ); + + assert_eq!(pane_action, Some(NavigateAction::LastPane)); + } + #[test] fn terminal_direct_indexed_tab_shortcut_maps_to_navigation_action() { let mut state = state_with_workspaces(&["test"]); @@ -1747,6 +1795,7 @@ navigate_pane_right = "ctrl+l" app.render_dirty.clone(), ) .expect("workspace should spawn"); + let root_pane = workspace.tabs[0].root_pane; app.state.workspaces = vec![workspace]; app.terminal_runtimes.insert(terminal.id.clone(), runtime); app.state.terminals.insert(terminal.id.clone(), terminal); @@ -1774,6 +1823,19 @@ navigate_pane_right = "ctrl+l" assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2); assert_eq!(app.terminal_runtimes.len(), 2); assert!(app.state.workspaces[0].tabs[0].zoomed); + let overlay_pane = app.state.workspaces[0].focused_pane_id().unwrap(); + assert_ne!(overlay_pane, root_pane); + + app.state.last_pane(); + + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root_pane)); + + app.state.last_pane(); + + assert_eq!( + app.state.workspaces[0].focused_pane_id(), + Some(overlay_pane) + ); let _ = wait_for_file(&output_path); let deadline = std::time::Instant::now() + Duration::from_secs(2); diff --git a/src/app/mod.rs b/src/app/mod.rs index e4ec2751..534a81d8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -392,6 +392,7 @@ impl App { direct_attach_resize_locks: std::collections::HashSet::new(), workspaces, active, + previous_pane_focus: None, selected, mode, should_quit: false, @@ -2550,6 +2551,9 @@ mod tests { .cwd = split_cwd.clone(); app.state.active = Some(0); app.state.selected = 0; + app.state + .focus_pane_in_workspace(0, background_previous_focus); + app.state.focus_pane_in_workspace(0, active_pane); let target_pane_id = app.pane_info(0, target_pane).unwrap().pane_id; let target_tab_id = app.public_tab_id(0, background_tab).unwrap(); @@ -2592,6 +2596,14 @@ mod tests { .pane_count(), 3 ); + app.state.last_pane(); + assert_eq!(app.state.workspaces[0].active_tab, background_tab); + assert_eq!( + app.state.workspaces[0].tabs[background_tab] + .layout + .focused(), + background_previous_focus + ); let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); for (_terminal_id, runtime) in runtimes { @@ -2650,6 +2662,44 @@ mod tests { } } + #[tokio::test] + async fn focused_agent_start_records_previous_pane() { + let mut app = test_app(); + let workspace = Workspace::test_new("agent-start-focus"); + let root = workspace.tabs[0].root_pane; + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req_agent_start_focus".into(), + method: crate::api::schema::Method::AgentStart(crate::api::schema::AgentStartParams { + name: "worker".into(), + cwd: None, + workspace_id: None, + tab_id: None, + split: Some(crate::api::schema::SplitDirection::Right), + focus: true, + argv: vec!["/usr/bin/true".into()], + }), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + + assert_eq!(response["result"]["type"], "agent_started"); + assert_ne!(app.state.workspaces[0].focused_pane_id(), Some(root)); + + app.state.last_pane(); + + assert_eq!(app.state.active, Some(0)); + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); + + let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); + for (_terminal_id, runtime) in runtimes { + runtime.shutdown(); + } + } + #[test] fn pane_close_request_closes_only_the_target_tab_when_other_tabs_exist() { let mut app = test_app(); @@ -2984,6 +3034,45 @@ mod tests { ); } + #[test] + fn route_client_input_prefix_tab_dispatches_global_last_pane() { + let config: Config = toml::from_str( + r#" +[keys] +last_pane = "prefix+tab" +"#, + ) + .unwrap(); + let mut app = test_app(); + let mut first = Workspace::test_new("one"); + let first_second_tab = first.test_add_tab(Some("logs")); + let first_second_root = first.tabs[first_second_tab].root_pane; + let second = Workspace::test_new("two"); + let second_root = second.tabs[0].root_pane; + app.state.workspaces = vec![first, second]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.keybinds = config.keybinds(); + app.state.mode = Mode::Terminal; + app.state.switch_workspace_tab(0, first_second_tab); + app.state.switch_workspace_tab(1, 0); + + app.route_client_input(vec![0x02, b'\t']); + + assert_eq!(app.state.mode, Mode::Terminal); + assert_eq!(app.state.active, Some(0)); + assert_eq!(app.state.workspaces[0].active_tab, first_second_tab); + assert_eq!( + app.state.workspaces[0].focused_pane_id(), + Some(first_second_root) + ); + + app.route_client_input(vec![0x02, b'\t']); + + assert_eq!(app.state.active, Some(1)); + assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(second_root)); + } + #[tokio::test] async fn route_client_input_double_prefix_passes_prefix_through_to_focused_pane() { let mut app = test_app(); diff --git a/src/app/state.rs b/src/app/state.rs index b22dc6a3..5e6aeafd 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1028,6 +1028,12 @@ pub enum SidebarWidthSource { Manual, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PaneFocusTarget { + pub workspace_id: String, + pub pane_id: PaneId, +} + /// All application state — pure data, no channels or async runtime. /// Testable without PTYs or a tokio runtime. pub struct AppState { @@ -1037,6 +1043,7 @@ pub struct AppState { pub direct_attach_resize_locks: std::collections::HashSet, pub workspaces: Vec, pub active: Option, + pub(crate) previous_pane_focus: Option, pub selected: usize, pub mode: Mode, pub should_quit: bool, @@ -1337,6 +1344,7 @@ impl AppState { direct_attach_resize_locks: std::collections::HashSet::new(), workspaces: Vec::new(), active: None, + previous_pane_focus: None, selected: 0, mode: Mode::Navigate, should_quit: false, diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 9235cf05..ea7f411c 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -288,6 +288,7 @@ pub struct Keybinds { pub focus_pane_right: ActionKeybinds, pub cycle_pane_next: ActionKeybinds, pub cycle_pane_previous: ActionKeybinds, + pub last_pane: ActionKeybinds, pub split_vertical: ActionKeybinds, pub split_horizontal: ActionKeybinds, pub close_pane: ActionKeybinds, @@ -463,6 +464,7 @@ impl Config { focus_pane_down: action!("keys.focus_pane_down", &self.keys.focus_pane_down), focus_pane_up: action!("keys.focus_pane_up", &self.keys.focus_pane_up), focus_pane_right: action!("keys.focus_pane_right", &self.keys.focus_pane_right), + last_pane: action!("keys.last_pane", &self.keys.last_pane), cycle_pane_next: action!("keys.cycle_pane_next", &self.keys.cycle_pane_next), cycle_pane_previous: action!( "keys.cycle_pane_previous", @@ -1264,6 +1266,12 @@ next_tab = "prefix+n" assert!(kb.remove_worktree.bindings.is_empty()); } + #[test] + fn back_and_forth_keybinds_are_unset_by_default() { + let kb = Config::default().keybinds(); + assert!(kb.last_pane.bindings.is_empty()); + } + #[test] fn array_bindings_allow_prefix_and_modified_direct() { let config: Config = toml::from_str( diff --git a/src/config/model.rs b/src/config/model.rs index 7434c924..d4830df1 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -211,6 +211,8 @@ pub struct KeysConfig { pub cycle_pane_next: BindingConfig, /// Cycle to the previous pane. Default: "prefix+shift+tab". pub cycle_pane_previous: BindingConfig, + /// Focus the last focused pane across workspaces and tabs. Unset by default. + pub last_pane: BindingConfig, /// Split pane vertically (side by side). Default: "prefix+v" pub split_vertical: BindingConfig, /// Split pane horizontally (stacked). Default: "prefix+minus" @@ -391,6 +393,7 @@ impl Default for KeysConfig { focus_pane_right: BindingConfig::one("prefix+l"), cycle_pane_next: BindingConfig::one("prefix+tab"), cycle_pane_previous: BindingConfig::one("prefix+shift+tab"), + last_pane: BindingConfig::empty(), split_vertical: BindingConfig::one("prefix+v"), split_horizontal: BindingConfig::one("prefix+minus"), close_pane: BindingConfig::one("prefix+x"), diff --git a/src/layout.rs b/src/layout.rs index be407589..fe608f01 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -147,20 +147,6 @@ impl TileLayout { } } - pub fn focus_next(&mut self) { - let ids = self.pane_ids(); - if let Some(pos) = ids.iter().position(|id| *id == self.focus) { - self.focus = ids[(pos + 1) % ids.len()]; - } - } - - pub fn focus_prev(&mut self) { - let ids = self.pane_ids(); - if let Some(pos) = ids.iter().position(|id| *id == self.focus) { - self.focus = ids[(pos + ids.len() - 1) % ids.len()]; - } - } - pub fn focus_pane(&mut self, id: PaneId) { if self.pane_ids().contains(&id) { self.focus = id; diff --git a/src/main.rs b/src/main.rs index 28dfd29d..0d42effe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -133,6 +133,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # focus_pane_right = "prefix+l" # cycle_pane_next = "prefix+tab" # cycle_pane_previous = "prefix+shift+tab" +# last_pane = "" # optional, unset by default; bind e.g. "prefix+tab" for global back-and-forth # split_vertical = "prefix+v" # split_horizontal = "prefix+minus" # close_pane = "prefix+x" diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs index 21f4baed..c3664dec 100644 --- a/src/ui/keybind_help.rs +++ b/src/ui/keybind_help.rs @@ -138,6 +138,7 @@ pub(super) fn keybind_help_groups( keybind_label(&kb.cycle_pane_previous), "cycle pane previous", ), + (keybind_label(&kb.last_pane), "last pane"), ]; groups.push(("panes", panes));