From 15ac8d12dbe54d5a6ea84eddd162300ab35604d2 Mon Sep 17 00:00:00 2001 From: Komsan Date: Tue, 4 Aug 2026 16:29:42 +0700 Subject: [PATCH 1/3] fix(input): leave navigate mode on ctrl+[ refs #1431 --- src/app/input/navigate.rs | 44 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 517e267d01..633d385cb3 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -112,10 +112,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 is_navigate_cancel_key(&raw_key) || self.state.is_prefix_key(&raw_key) { leave_navigate_mode(&mut self.state); return; } @@ -1383,7 +1382,7 @@ pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { state.update_dismissed = true; let terminal_key = TerminalKey::from(key); - if state.is_prefix_key(&terminal_key) || key.code == KeyCode::Esc { + if state.is_prefix_key(&terminal_key) || is_navigate_cancel_key(&terminal_key) { leave_navigate_mode(state); return; } @@ -1952,6 +1951,19 @@ fn move_active_tab_relative(state: &mut AppState, delta: isize) { } } +/// True for Esc and for Ctrl+[, its terminal-level equivalent. +/// +/// 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. +fn is_navigate_cancel_key(key: &TerminalKey) -> bool { + key.code == KeyCode::Esc + || (key.code == KeyCode::Char('[') + && key.modifiers == crossterm::event::KeyModifiers::CONTROL) +} + fn leave_navigate_mode(state: &mut AppState) { if state.active.is_some() { state.mode = Mode::Terminal; @@ -3274,6 +3286,32 @@ 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_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"]); From 8f5c3bf56c3a4f762a172952ec4709dd0cdf30a7 Mon Sep 17 00:00:00 2001 From: Komsan Date: Thu, 6 Aug 2026 09:13:28 +0700 Subject: [PATCH 2/3] fix(input): check ctrl+[ after navigate keybindings refs #1431 --- src/app/input/navigate.rs | 41 +++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 633d385cb3..c6d29e0aff 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -114,7 +114,7 @@ impl App { pub(crate) fn handle_navigate_key(&mut self, raw_key: TerminalKey) { self.state.update_dismissed = true; - if is_navigate_cancel_key(&raw_key) || 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; } @@ -163,6 +163,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); } } @@ -1382,7 +1387,7 @@ pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { state.update_dismissed = true; let terminal_key = TerminalKey::from(key); - if state.is_prefix_key(&terminal_key) || is_navigate_cancel_key(&terminal_key) { + if state.is_prefix_key(&terminal_key) || key.code == KeyCode::Esc { leave_navigate_mode(state); return; } @@ -1391,13 +1396,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); } } @@ -1951,17 +1961,18 @@ fn move_active_tab_relative(state: &mut AppState, delta: isize) { } } -/// True for Esc and for Ctrl+[, its terminal-level equivalent. +/// 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. -fn is_navigate_cancel_key(key: &TerminalKey) -> bool { - key.code == KeyCode::Esc - || (key.code == KeyCode::Char('[') - && key.modifiers == crossterm::event::KeyModifiers::CONTROL) +/// +/// 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 } fn leave_navigate_mode(state: &mut AppState) { @@ -3299,6 +3310,20 @@ command = "printf literal > '{}'" 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"]); From c44de1e1bd5e1bffb63c869b7db818e260fc661c Mon Sep 17 00:00:00 2001 From: Komsan Date: Tue, 25 Aug 2026 13:01:36 +0700 Subject: [PATCH 3/3] docs: note ctrl+[ navigate mode fix in changelog refs #1431 --- docs/next/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index c94c15e244..523c64d5fd 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -7,6 +7,7 @@ ### Fixed - Claude Code integration hooks now ignore Cursor CLI's Claude-compatible session events, preventing Cursor sessions from being stored as resumable Claude sessions. (#2832) +- Navigate mode now closes on `Ctrl+[` like Esc under the kitty keyboard protocol, while a configured `ctrl+[` binding keeps priority. (#1431, thanks @haoxianhan) - Running named servers now activate remote agent-detection manifests downloaded by another server, preventing stale agent states and `agent explain` output until restart. (#2711) - New lifecycle event subscriptions now stream only events emitted after subscription begins instead of replaying retained history. (#1270) - Windows users whose endpoint security blocks the fileless PowerShell install command can now use a local `install.cmd` bootstrap; installer downloads use `curl.exe` while preserving package checksum verification. (#2751)