diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index 413e29f52..c7664f290 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -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, diff --git a/src/client/shell/tests/workspace_navigation.rs b/src/client/shell/tests/workspace_navigation.rs index c117a3194..dff7f7d41 100644 --- a/src/client/shell/tests/workspace_navigation.rs +++ b/src/client/shell/tests/workspace_navigation.rs @@ -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()); +}