diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index fd6ef2732b..260c2f8471 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -125,10 +125,9 @@ impl App { } pub(crate) fn handle_navigate_key(&mut self, raw_key: TerminalKey) { - let key = raw_key.as_key_event(); self.state.update_dismissed = true; - if key.code == KeyCode::Esc || self.state.is_prefix_key(&raw_key) { + if raw_key.code == KeyCode::Esc || self.state.is_prefix_key(&raw_key) { leave_navigate_mode(&mut self.state); return; } @@ -177,6 +176,11 @@ impl App { if let Some(action) = navigate_mode_indexed_action_for_key(&self.state, &raw_key) { self.execute_tui_navigate_action(action, ActionContext::Navigate); self.selection_autoscroll_deadline = None; + return; + } + + if is_ctrl_bracket_key(&raw_key) { + leave_navigate_mode(&mut self.state); } } @@ -1367,13 +1371,18 @@ pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { return; } - if let Some(action) = navigate_mode_action_for_key(state, terminal_key) { + if let Some(action) = navigate_mode_action_for_key(state, terminal_key.clone()) { execute_navigate_action_in_context( state, &mut terminal_runtimes, action, ActionContext::Navigate, ); + return; + } + + if is_ctrl_bracket_key(&terminal_key) { + leave_navigate_mode(state); } } @@ -1893,6 +1902,20 @@ fn workspace_can_start_worktree_action( !git_space.is_some_and(|space| space.is_linked_worktree) } +/// True for Ctrl+[, the terminal-level equivalent of Esc. +/// +/// A legacy terminal sends Ctrl+[ as 0x1b, the same byte as Esc, so it already +/// arrives as `KeyCode::Esc`. Under the kitty keyboard protocol, which Herdr +/// negotiates, the modified key is reported on its own and reaches us as +/// `Char('[')` with CONTROL. Ctrl+Shift+[ stays distinct because it carries +/// SHIFT. +/// +/// Callers check this only after navigate-mode keybinding dispatch, so a +/// configured Ctrl+[ binding keeps working and this stays a fallback cancel. +fn is_ctrl_bracket_key(key: &TerminalKey) -> bool { + key.code == KeyCode::Char('[') && key.modifiers == crossterm::event::KeyModifiers::CONTROL +} + // Translate a one-step move into the pre-removal insertion slot that // Workspace::move_tab expects, wrapping at either end. None when there is // nothing to move. @@ -3160,6 +3183,46 @@ command = "printf literal > '{}'" assert_eq!(app.state.mode, Mode::Navigate); } + #[test] + fn app_navigate_mode_ctrl_bracket_leaves_like_esc() { + let mut app = app_with_test_workspaces(&["one", "two"]); + + app.state.mode = Mode::Navigate; + app.handle_navigate_key(TerminalKey::new(KeyCode::Char('['), KeyModifiers::CONTROL)); + assert_eq!(app.state.mode, Mode::Terminal); + + app.state.mode = Mode::Navigate; + app.handle_navigate_key(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty())); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[test] + fn app_navigate_mode_configured_ctrl_bracket_binding_wins_over_cancel() { + let mut app = app_with_test_workspaces(&["one", "two"]); + let config: Config = + toml::from_str("[keys]\nnavigate_workspace_down = \"ctrl+[\"\n").unwrap(); + app.state.keybinds = config.keybinds(); + app.state.mode = Mode::Navigate; + + app.handle_navigate_key(TerminalKey::new(KeyCode::Char('['), KeyModifiers::CONTROL)); + + assert_eq!(app.state.selected, 1); + assert_eq!(app.state.mode, Mode::Navigate); + } + + #[test] + fn app_navigate_mode_ctrl_shift_bracket_stays_open() { + let mut app = app_with_test_workspaces(&["one", "two"]); + app.state.mode = Mode::Navigate; + + app.handle_navigate_key(TerminalKey::new( + KeyCode::Char('['), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )); + + assert_eq!(app.state.mode, Mode::Navigate); + } + #[test] fn app_navigate_mode_maps_french_number_row_to_workspace() { let mut app = app_with_test_workspaces(&["one", "two"]);