aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-26 14:48:57 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-26 14:50:13 +0300
commit57c880f0ebaa5011abc90f4e2fd4f84fc072ce35 (patch)
tree9f662b19cec2e9c9e50e87eb5e96def9e8ae9822 /src/app
parent9eec3029e13b791b8a4954e38d5bb7db6e2ff68f (diff)
feat: add global last-pane navigation
refs #287
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs353
-rw-r--r--src/app/agents.rs18
-rw-r--r--src/app/api/panes.rs6
-rw-r--r--src/app/api/tabs.rs6
-rw-r--r--src/app/creation.rs2
-rw-r--r--src/app/input/mod.rs8
-rw-r--r--src/app/input/mouse.rs40
-rw-r--r--src/app/input/navigate.rs62
-rw-r--r--src/app/mod.rs89
-rw-r--r--src/app/state.rs8
10 files changed, 504 insertions, 88 deletions
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<PaneFocusTarget> {
+ 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<PaneFocusTarget>,
+ 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<PaneFocusTarget>) {
+ 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,7 +693,51 @@ 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) {
@@ -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) {
+ 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 {
+ 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
- .active
- .and_then(|i| self.workspaces.get_mut(i))
- .and_then(|ws| ws.active_tab_mut())
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(tab_idx))
{
- if reverse {
- tab.layout.focus_prev();
- } else {
- tab.layout.focus_next();
- }
+ tab.layout.focus_pane(target.pane_id);
+ self.previous_pane_focus = current;
self.mark_session_dirty();
}
}
@@ -2515,6 +2648,18 @@ mod tests {
}
#[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");
let first_root = first.tabs[0].root_pane;
@@ -2578,6 +2723,122 @@ mod tests {
}
#[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"]);
crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14));
@@ -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<crate::api::schema::AgentInfo, TerminalTargetError> {
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, &params.target_pane_id);
};
@@ -57,8 +58,9 @@ impl App {
None => return pane_not_found(id, &params.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 => {
@@ -1454,6 +1468,40 @@ navigate_pane_right = "ctrl+l"
}
#[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"]);
let config: Config = toml::from_str("[keys]\nswitch_tab = \"ctrl+3\"\n").unwrap();
@@ -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<crate::terminal::TerminalId>,
pub workspaces: Vec<Workspace>,
pub active: Option<usize>,
+ pub(crate) previous_pane_focus: Option<PaneFocusTarget>,
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,