From 3fcae3270ada8d83df091a7f0a66923e38a58029 Mon Sep 17 00:00:00 2001 From: Adrien Barret Date: Wed, 12 Aug 2026 17:34:38 -0400 Subject: [PATCH] feat(input): add persistent focus-navigation mode Add a new Mode::FocusNav: a sticky navigation mode modeled on resize mode. Enter it via the configurable `focus_nav` keybind (unset by default) and it stays open so you can chain movements without re-pressing the prefix each time: h/j/k/l or arrows -> move pane focus Tab / Shift+Tab -> next / previous tab ] / [ -> next / previous workspace esc / enter -> exit Unlike the existing one-shot prefix actions and the navigator/goto pickers, focus stays in the mode until Escape, giving a vim-like modal navigation layer over panes, tabs, and workspaces. --- src/app/input/mod.rs | 1 + src/app/input/modal.rs | 59 +++++++++++++++++++++++++++++++++++++++ src/app/input/navigate.rs | 8 ++++-- src/app/mod.rs | 3 ++ src/app/state.rs | 2 ++ src/config/keybinds.rs | 3 ++ src/config/model.rs | 9 ++++++ src/ui.rs | 6 ++-- src/ui/menus.rs | 29 +++++++++++++++++++ 9 files changed, 116 insertions(+), 4 deletions(-) diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 3202ae2663..0c00dd3c9f 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -107,6 +107,7 @@ impl App { Mode::OpenExistingWorktree => self.handle_worktree_open_key(key_event), Mode::ConfirmRemoveWorktree => self.handle_worktree_remove_key(key_event), Mode::Resize => self.handle_resize_key_via_api(key), + Mode::FocusNav => self.handle_focus_nav_key(key), Mode::ConfirmClose => self.handle_confirm_close_key_via_api(key_event), Mode::ContextMenu => { self.handle_context_menu_key_via_api(key_event); diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 343fa99c92..e3ac5b27c5 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -1158,6 +1158,65 @@ impl App { } } + /// Persistent focus-navigation mode (like resize mode, but moves focus). + /// Stays open so you can chain moves; esc/enter or re-pressing the trigger exits. + /// h/j/k/l or arrows -> focus pane in that direction + /// Tab / Shift+Tab -> next / previous tab + /// ] / [ -> next / previous workspace + pub(crate) fn handle_focus_nav_key(&mut self, raw_key: TerminalKey) { + let key = raw_key.as_key_event(); + self.state.update_dismissed = true; + + if key.code == KeyCode::Esc + || key.code == KeyCode::Enter + || self.state.keybinds.focus_nav.matches_prefix_key(&raw_key) + || self.state.keybinds.focus_nav.matches_direct_key(&raw_key) + { + self.state.mode = if self.state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + return; + } + + match key.code { + KeyCode::Char('h') | KeyCode::Left => { + self.focus_pane_direction_via_api(NavDirection::Left) + } + KeyCode::Char('j') | KeyCode::Down => { + self.focus_pane_direction_via_api(NavDirection::Down) + } + KeyCode::Char('k') | KeyCode::Up => { + self.focus_pane_direction_via_api(NavDirection::Up) + } + KeyCode::Char('l') | KeyCode::Right => { + self.focus_pane_direction_via_api(NavDirection::Right) + } + KeyCode::Tab => { + if let Some(tab_idx) = self.relative_tab(1) { + self.focus_tab_idx_via_api(tab_idx); + } + } + KeyCode::BackTab => { + if let Some(tab_idx) = self.relative_tab(-1) { + self.focus_tab_idx_via_api(tab_idx); + } + } + KeyCode::Char(']') => { + if let Some(ws_idx) = self.relative_visible_workspace(1) { + self.focus_workspace_idx_via_api(ws_idx); + } + } + KeyCode::Char('[') => { + if let Some(ws_idx) = self.relative_visible_workspace(-1) { + self.focus_workspace_idx_via_api(ws_idx); + } + } + _ => {} + } + } + pub(crate) fn handle_confirm_close_key_via_api(&mut self, key: KeyEvent) { match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) { Some(ModalAction::Confirm) => { diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index beb2f363f0..5ef05993cb 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -395,6 +395,7 @@ impl App { leave_navigate_mode(&mut self.state); } NavigateAction::EnterResizeMode => self.state.mode = Mode::Resize, + NavigateAction::EnterFocusNav => self.state.mode = Mode::FocusNav, NavigateAction::ResizePaneLeft => { self.resize_pane_direction_via_api(NavDirection::Left); leave_navigate_mode(&mut self.state); @@ -750,7 +751,7 @@ impl App { Some((ws_idx, focused.id, target)) } - fn relative_visible_workspace(&self, delta: isize) -> Option { + pub(super) fn relative_visible_workspace(&self, delta: isize) -> Option { let order = self.state.visible_workspace_order(); if order.is_empty() { return None; @@ -769,7 +770,7 @@ impl App { Some((ws_idx, source, insert)) } - fn relative_tab(&self, delta: isize) -> Option { + pub(super) fn relative_tab(&self, delta: isize) -> Option { let ws = self .state .active @@ -1416,6 +1417,7 @@ pub(crate) enum NavigateAction { CopyMode, Zoom, EnterResizeMode, + EnterFocusNav, ResizePaneLeft, ResizePaneDown, ResizePaneUp, @@ -1566,6 +1568,7 @@ fn non_indexed_action_for_key( (&kb.close_pane, NavigateAction::ClosePane), (&kb.zoom, NavigateAction::Zoom), (&kb.resize_mode, NavigateAction::EnterResizeMode), + (&kb.focus_nav, NavigateAction::EnterFocusNav), (&kb.resize_pane_left, NavigateAction::ResizePaneLeft), (&kb.resize_pane_down, NavigateAction::ResizePaneDown), (&kb.resize_pane_up, NavigateAction::ResizePaneUp), @@ -1809,6 +1812,7 @@ pub(super) fn execute_navigate_action_in_context( leave_navigate_mode(state); } NavigateAction::EnterResizeMode => state.mode = Mode::Resize, + NavigateAction::EnterFocusNav => state.mode = Mode::FocusNav, NavigateAction::ResizePaneLeft => { state.resize_pane(NavDirection::Left); leave_navigate_mode(state); diff --git a/src/app/mod.rs b/src/app/mod.rs index d3d7995442..870f8191af 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1903,6 +1903,9 @@ impl App { Mode::Resize => { self.handle_resize_key_via_api(key); } + Mode::FocusNav => { + self.handle_focus_nav_key(key); + } Mode::ConfirmClose => { self.handle_confirm_close_key_via_api(key_event); } diff --git a/src/app/state.rs b/src/app/state.rs index cbecef00dc..ec300950ca 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -792,6 +792,7 @@ pub enum Mode { OpenExistingWorktree, ConfirmRemoveWorktree, Resize, + FocusNav, ConfirmClose, ContextMenu, Settings, @@ -823,6 +824,7 @@ impl Mode { | Mode::Navigator | Mode::Copy | Mode::Resize + | Mode::FocusNav | Mode::ConfirmClose | Mode::ConfirmRemoveWorktree | Mode::ContextMenu diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 6cddd3acdf..36240523ae 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -352,6 +352,7 @@ pub struct Keybinds { pub close_pane: ActionKeybinds, pub zoom: ActionKeybinds, pub resize_mode: ActionKeybinds, + pub focus_nav: ActionKeybinds, pub resize_pane_left: ActionKeybinds, pub resize_pane_down: ActionKeybinds, pub resize_pane_up: ActionKeybinds, @@ -520,6 +521,7 @@ impl Config { close_pane: empty_action!(), zoom: empty_action!(), resize_mode: empty_action!(), + focus_nav: empty_action!(), resize_pane_left: empty_action!(), resize_pane_down: empty_action!(), resize_pane_up: empty_action!(), @@ -667,6 +669,7 @@ impl Config { apply_action!(keybinds.close_pane, close_pane, source); apply_action!(keybinds.zoom, zoom, source); apply_action!(keybinds.resize_mode, resize_mode, source); + apply_action!(keybinds.focus_nav, focus_nav, source); apply_action!(keybinds.resize_pane_left, resize_pane_left, source); apply_action!(keybinds.resize_pane_down, resize_pane_down, source); apply_action!(keybinds.resize_pane_up, resize_pane_up, source); diff --git a/src/config/model.rs b/src/config/model.rs index 9b5904fbb4..da757039f2 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -442,6 +442,10 @@ pub struct KeysConfig { pub zoom: BindingConfig, /// Enter resize mode. Default: "prefix+r" pub resize_mode: BindingConfig, + /// Enter focus-navigation mode: a persistent mode where h/j/k/l move pane + /// focus, Tab/Shift+Tab switch tabs, and ] / [ switch workspaces, staying + /// open until Escape. Unset by default. + pub focus_nav: BindingConfig, /// Resize the focused pane toward the left. Unset by default. pub resize_pane_left: BindingConfig, /// Resize the focused pane downward. Unset by default. @@ -573,6 +577,8 @@ pub(crate) struct KeysConfigOverlay { #[serde(skip_serializing_if = "Option::is_none")] resize_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] + focus_nav: Option, + #[serde(skip_serializing_if = "Option::is_none")] resize_pane_left: Option, #[serde(skip_serializing_if = "Option::is_none")] resize_pane_down: Option, @@ -659,6 +665,7 @@ impl<'de> Deserialize<'de> for KeysConfig { apply_field!(close_pane); apply_field!(zoom); apply_field!(resize_mode); + apply_field!(focus_nav); apply_field!(resize_pane_left); apply_field!(resize_pane_down); apply_field!(resize_pane_up); @@ -763,6 +770,7 @@ impl KeysConfig { copy_effective_action_field!(close_pane, keybinds.close_pane); copy_effective_action_field!(zoom, keybinds.zoom); copy_effective_action_field!(resize_mode, keybinds.resize_mode); + copy_effective_action_field!(focus_nav, keybinds.focus_nav); copy_effective_action_field!(resize_pane_left, keybinds.resize_pane_left); copy_effective_action_field!(resize_pane_down, keybinds.resize_pane_down); copy_effective_action_field!(resize_pane_up, keybinds.resize_pane_up); @@ -1064,6 +1072,7 @@ impl Default for KeysConfig { close_pane: BindingConfig::one("prefix+x"), zoom: BindingConfig::one("prefix+z"), resize_mode: BindingConfig::one("prefix+r"), + focus_nav: BindingConfig::empty(), resize_pane_left: BindingConfig::empty(), resize_pane_down: BindingConfig::empty(), resize_pane_up: BindingConfig::empty(), diff --git a/src/ui.rs b/src/ui.rs index 819644221f..4ad5085810 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -28,8 +28,9 @@ use self::dialogs::{ }; use self::keybind_help::render_keybind_help_overlay; use self::menus::{ - render_context_menu, render_copy_mode_overlay, render_global_launcher_menu, - render_navigate_overlay, render_prefix_overlay, render_resize_overlay, + render_context_menu, render_copy_mode_overlay, render_focus_nav_overlay, + render_global_launcher_menu, render_navigate_overlay, render_prefix_overlay, + render_resize_overlay, }; use self::mobile::{ compute_mobile_header_hit_areas, is_mobile_width, mobile_switcher_max_scroll_for_height, @@ -439,6 +440,7 @@ pub fn render_with_runtime_registry( Mode::Prefix => render_prefix_overlay(app, frame, mode_bar_area), Mode::Copy => render_copy_mode_overlay(app, frame, mode_bar_area), Mode::Resize => render_resize_overlay(app, frame, mode_bar_area), + Mode::FocusNav => render_focus_nav_overlay(app, frame, mode_bar_area), Mode::ConfirmClose => { render_confirm_close_overlay(app, terminal_runtimes, frame, terminal_area) } diff --git a/src/ui/menus.rs b/src/ui/menus.rs index 763c0ea8c7..3df89e6be8 100644 --- a/src/ui/menus.rs +++ b/src/ui/menus.rs @@ -283,6 +283,35 @@ pub(super) fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rec render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); } +pub(super) fn render_focus_nav_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + let key = Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD); + let dim = Style::default().fg(app.palette.overlay0); + + let mode_style = Style::default() + .fg(panel_contrast_fg(&app.palette)) + .bg(app.palette.green) + .add_modifier(Modifier::BOLD); + + let line = Line::from(vec![ + Span::styled(" NAV ", mode_style), + Span::raw(" "), + Span::styled("hjkl", key), + Span::styled(" pane ", dim), + Span::styled("⇥", key), + Span::styled(" tab ", dim), + Span::styled("[ ]", key), + Span::styled(" ws ", dim), + Span::styled("esc", key), + Span::styled(" done", dim), + ]); + + let overlay_y = area.y + area.height.saturating_sub(1); + let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); + render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg); +} + pub(super) fn render_context_menu(app: &AppState, frame: &mut Frame) { let Some(menu) = &app.context_menu else { return;