fix(input): leave navigate mode on ctrl+[ (#2340)

refs #1431
This commit is contained in:
Komsan Nipharat
2026-10-05 14:33:28 +03:00
committed by GitHub
parent 49848effb0
commit 040d345d3b
2 changed files with 86 additions and 3 deletions
+28 -3
View File
@@ -10,6 +10,19 @@ fn is_retained_selection_copy_key(key: &crate::input::TerminalKey) -> bool {
&& matches!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SUPER)
}
/// 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 the modified key
/// is reported on its own and reaches navigate mode as `Char('[')` with CONTROL.
/// Ctrl+Shift+[ stays distinct because it carries SHIFT.
///
/// Navigate mode checks this only after keybinding dispatch, so a configured
/// Ctrl+[ binding keeps working and this stays a fallback cancel.
fn is_ctrl_bracket_key(key: &crate::input::TerminalKey) -> bool {
key.code == KeyCode::Char('[') && key.modifiers == KeyModifiers::CONTROL
}
pub(super) fn is_modal_paste_shortcut_for_platform(
key: &crate::input::TerminalKey,
macos: bool,
@@ -650,9 +663,7 @@ impl ClientShellState {
self.pending_workspace_highlight = None;
if key.code == KeyCode::Esc || self.config.keybinds.matches_prefix(key) {
self.mode = self.copy_or_terminal_mode();
self.navigate_workspace_id = None;
outcome.repaint = true;
self.cancel_navigate(outcome);
return;
}
@@ -687,6 +698,12 @@ impl ClientShellState {
return;
}
if self.workspace_preview_action_blocked() {
// Bindings cannot run against a foreign preview, so Ctrl+[ has
// nothing to lose to and cancels here the same way Esc does.
if is_ctrl_bracket_key(key) {
self.cancel_navigate(outcome);
return;
}
self.push_endpoint_notice(
ClientEndpointNoticeKind::Rejected,
"navigate_endpoint_inactive",
@@ -816,9 +833,17 @@ impl ClientShellState {
});
if let Some(binding) = binding {
self.record_navigate_binding(binding, false, outcome);
} else if is_ctrl_bracket_key(key) {
self.cancel_navigate(outcome);
}
}
fn cancel_navigate(&mut self, outcome: &mut ClientShellInput) {
self.mode = self.copy_or_terminal_mode();
self.navigate_workspace_id = None;
outcome.repaint = true;
}
fn record_navigate_binding(
&mut self,
binding: crate::input::KeybindMatch,
@@ -968,3 +968,61 @@ fn navigation_highlight_ends_for_noop_focus_and_focused_creation() {
}
}
}
// Kitty keyboard protocol (CSI u) encodings of `[` (codepoint 91).
const KITTY_CTRL_BRACKET: &[u8] = b"\x1b[91;5u";
const KITTY_CTRL_SHIFT_BRACKET: &[u8] = b"\x1b[91;6u";
#[test]
fn kitty_ctrl_bracket_leaves_navigation_like_esc() {
let mut state = local_navigation_state(false);
enter_navigation(&mut state);
preview_key(&mut state, b"\x1b[B");
preview_key(&mut state, KITTY_CTRL_BRACKET);
assert_eq!(state.mode, ClientShellMode::Terminal);
assert!(state.navigate_workspace_id.is_none());
}
#[test]
fn configured_ctrl_bracket_navigate_binding_wins_over_cancel() {
let config: Config = toml::from_str("[keys]\nnavigate_workspace_down = \"ctrl+[\"\n").unwrap();
let mut state = ClientShellState::new(ClientShellConfig::from_config(&config));
state.set_snapshot(Box::new(workspaces(3)));
state.set_pane_surface(surface());
state.compose(100, 28).unwrap();
enter_navigation(&mut state);
assert_selected(&state, &ClientEndpointId::Local, "ws_1");
preview_key(&mut state, KITTY_CTRL_BRACKET);
assert_eq!(state.mode, ClientShellMode::Navigate);
assert_selected(&state, &ClientEndpointId::Local, "ws_2");
}
#[test]
fn kitty_ctrl_shift_bracket_keeps_navigation_open() {
let mut state = local_navigation_state(false);
enter_navigation(&mut state);
state.handle_input_bytes(KITTY_CTRL_SHIFT_BRACKET);
assert_eq!(state.mode, ClientShellMode::Navigate);
assert_selected(&state, &ClientEndpointId::Local, "ws_1");
}
#[test]
fn kitty_ctrl_bracket_cancels_a_blocked_foreign_preview() {
let (mut state, remote) = state_with_remote();
state.compose(100, 28).unwrap();
enter_navigation(&mut state);
preview_key(&mut state, b"\x1b[B");
assert_selected(&state, &remote, "ws_1");
assert!(state.workspace_preview_action_blocked());
preview_key(&mut state, KITTY_CTRL_BRACKET);
assert_ne!(state.mode, ClientShellMode::Navigate);
assert!(state.navigate_workspace_id.is_none());
}