返回 CodeWhale
key_shortcuts.rs
根目录 / crates / tui / src / tui / key_shortcuts.rs
1 //! Keyboard-shortcut predicates and platform-specific labels.
2 //!
3 //! These helpers normalise the cross-platform variations between
4 //! `Ctrl+…` (Linux/Windows) and `Cmd+…` (macOS), legacy `Ctrl+H`-as-
5 //! backspace handling, and the macOS Option-Latin-character escapes.
6 //! Centralising them
7 //! keeps the composer / transcript event loops in `ui.rs` short and
8 //! lets us add a new platform without touching the call sites.
9
10 use std::borrow::Cow;
11
12 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
13
14 pub(super) fn has_control_like_modifier(modifiers: KeyModifiers) -> bool {
15 has_control_like_modifier_for_platform(modifiers, cfg!(target_os = "macos"))
16 }
17
18 pub(super) fn has_control_like_modifier_for_platform(
19 modifiers: KeyModifiers,
20 is_macos: bool,
21 ) -> bool {
22 modifiers.contains(KeyModifiers::CONTROL)
23 || (is_macos && modifiers.contains(KeyModifiers::SUPER))
24 }
25
26 /// Compatibility path for enhanced terminal clients that forward `Cmd+C` or
27 /// `Ctrl+Shift+C` as key events. Most terminals consume these locally, so the
28 /// user-visible Codewhale binding remains `Ctrl+C` with an active selection.
29 pub(super) fn is_copy_shortcut(key: &KeyEvent) -> bool {
30 let is_c = matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'));
31 if !is_c {
32 return false;
33 }
34
35 if key.modifiers.contains(KeyModifiers::SUPER) {
36 return true;
37 }
38
39 key.modifiers.contains(KeyModifiers::CONTROL) && key.modifiers.contains(KeyModifiers::SHIFT)
40 }
41
42 /// Toggle the file-tree pane: `Ctrl+Shift+E` on Linux/Windows or
43 /// `Cmd+Shift+E` on macOS.
44 pub(super) fn is_file_tree_toggle_shortcut(key: &KeyEvent) -> bool {
45 let is_shifted_e = matches!(key.code, KeyCode::Char('E'))
46 || (matches!(key.code, KeyCode::Char('e')) && key.modifiers.contains(KeyModifiers::SHIFT));
47 if !is_shifted_e {
48 return false;
49 }
50
51 let has_forbidden_modifier =
52 key.modifiers.contains(KeyModifiers::ALT) || key.modifiers.contains(KeyModifiers::SUPER);
53 let ctrl_shift_e = key.modifiers.contains(KeyModifiers::CONTROL) && !has_forbidden_modifier;
54
55 let cmd_shift_e = key.modifiers.contains(KeyModifiers::SUPER)
56 && key.modifiers.contains(KeyModifiers::SHIFT)
57 && !key.modifiers.contains(KeyModifiers::CONTROL)
58 && !key.modifiers.contains(KeyModifiers::ALT);
59
60 ctrl_shift_e || cmd_shift_e
61 }
62
63 pub(super) fn tool_details_shortcut_label() -> Cow<'static, str> {
64 crate::tui::shell_key_routing::tool_details_chord()
65 }
66
67 /// Compact affordance: platform chord + short verb (`⌥V:output`, `Alt+V:list`).
68 /// Matches footer notation (`cap:verb`); not a sentence.
69 pub(super) fn tool_details_shortcut_action_hint(verb: &str) -> String {
70 format!("{}:{verb}", tool_details_shortcut_label())
71 }
72
73 /// Open the full reasoning detail pager for the selected or current turn.
74 /// Ctrl+O now shows the recorded reasoning timeline, not the whole-turn
75 /// inspector (#v092-reasoning-fix).
76 pub(super) fn is_reasoning_detail_shortcut(key: &KeyEvent) -> bool {
77 matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O'))
78 && key.modifiers.contains(KeyModifiers::CONTROL)
79 && !key
80 .modifiers
81 .intersects(KeyModifiers::SHIFT | KeyModifiers::ALT | KeyModifiers::SUPER)
82 }
83
84 /// Open the whole-turn inspector on a dedicated, collision-free chord.
85 /// Ctrl+Alt+O was free in the keybinding registry; it is distinct from
86 /// Ctrl+O (reasoning detail) and Ctrl+Shift+O (external editor).
87 pub(super) fn is_turn_inspector_shortcut(key: &KeyEvent) -> bool {
88 matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O'))
89 && key.modifiers.contains(KeyModifiers::CONTROL)
90 && key.modifiers.contains(KeyModifiers::ALT)
91 && !key
92 .modifiers
93 .intersects(KeyModifiers::SHIFT | KeyModifiers::SUPER)
94 }
95
96 /// Open the composer draft in `$VISUAL` / `$EDITOR` without colliding with
97 /// the reasoning detail or Turn Inspector shortcuts. Enhanced protocols can
98 /// report either character case, but SHIFT must be explicit so Windows Caps
99 /// Lock cannot misroute Ctrl+O. F4 is the fallback for legacy protocols that
100 /// cannot encode Ctrl+Shift+O.
101 pub(super) fn is_external_editor_shortcut(key: &KeyEvent) -> bool {
102 let ctrl_shift_o = matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O'))
103 && key.modifiers.contains(KeyModifiers::CONTROL)
104 && key.modifiers.contains(KeyModifiers::SHIFT)
105 && !key
106 .modifiers
107 .intersects(KeyModifiers::ALT | KeyModifiers::SUPER);
108 let f4 = matches!(key.code, KeyCode::F(4)) && key.modifiers.is_empty();
109 ctrl_shift_o || f4
110 }
111
112 /// Select the whole composer draft. `Ctrl+A` is intentionally NOT select-all:
113 /// it keeps its readline meaning (jump to start of input), matching every
114 /// other emacs-style binding in the composer. Select-all is therefore:
115 ///
116 /// - `Ctrl+Shift+A` on every platform (mirrors `Ctrl+Shift+O` / `Ctrl+Shift+E`
117 /// precedent for shifted-Ctrl chords; requires an enhanced-keyboard
118 /// terminal, like those precedents).
119 /// - `Cmd+A` on macOS terminals that forward Cmd to the app (kitty, WezTerm,
120 /// iTerm2 with "Left/Right Command" remapping). The event-loop macOS
121 /// normalization deliberately skips this chord so `Cmd+A` is not collapsed
122 /// into readline `Ctrl+A`. `Cmd+Shift+A` also lands here after
123 /// normalization.
124 pub(super) fn is_select_all_shortcut(key: &KeyEvent) -> bool {
125 let is_a = matches!(key.code, KeyCode::Char('a') | KeyCode::Char('A'));
126 if !is_a {
127 return false;
128 }
129 let cmd_a = key.modifiers.contains(KeyModifiers::SUPER)
130 && !key
131 .modifiers
132 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT);
133 let ctrl_shift_a = key.modifiers.contains(KeyModifiers::CONTROL)
134 && key.modifiers.contains(KeyModifiers::SHIFT)
135 && !key
136 .modifiers
137 .intersects(KeyModifiers::ALT | KeyModifiers::SUPER);
138 cmd_a || ctrl_shift_a
139 }
140
141 /// Modifier predicate for the v0.8.30 family of `Alt+<key>` transcript-
142 /// nav shortcuts (`Alt+G` / `Alt+[` / `Alt+]` / `Alt+?` / `Alt+L`). Requires
143 /// `Alt` and disallows `Ctrl` / `Super` so the
144 /// bindings don't collide with platform clipboard / window-management
145 /// shortcuts. `Shift` is permitted so the capital-letter forms work on
146 /// any keyboard layout that produces them as `Alt+Shift+key`.
147 ///
148 /// Plain `Char` events (no modifier, or modifier=`Shift` alone for the
149 /// uppercase form) fall through to text insertion, which is the whole
150 /// point — typing "good morning" no longer eats the first `g`.
151 pub(super) fn alt_nav_modifiers(modifiers: KeyModifiers) -> bool {
152 modifiers.contains(KeyModifiers::ALT)
153 && !modifiers.contains(KeyModifiers::CONTROL)
154 && !modifiers.contains(KeyModifiers::SUPER)
155 }
156
157 pub(super) fn is_macos_option_v_legacy_key(key: &KeyEvent) -> bool {
158 is_macos_option_v_legacy_key_for_platform(key, cfg!(target_os = "macos"))
159 }
160
161 pub(super) fn is_macos_option_v_legacy_key_for_platform(key: &KeyEvent, is_macos: bool) -> bool {
162 is_macos && key.modifiers.is_empty() && matches!(key.code, KeyCode::Char('\u{221A}'))
163 }
164
165 /// Paste-from-clipboard: accept `Cmd+V`, `Ctrl+V`, or the legacy raw `\u{16}`
166 /// byte some terminals emit. A remote terminal normally consumes its local
167 /// paste chord and sends an `Event::Paste`; accepting both modifier families
168 /// still keeps enhanced-keyboard clients independent of the remote host OS.
169 pub(super) fn is_paste_shortcut(key: &KeyEvent) -> bool {
170 let is_v = matches!(key.code, KeyCode::Char('v') | KeyCode::Char('V'));
171 let is_legacy_ctrl_v = matches!(key.code, KeyCode::Char('\u{16}'));
172 if !is_v && !is_legacy_ctrl_v {
173 return false;
174 }
175
176 if is_legacy_ctrl_v {
177 return true;
178 }
179
180 // Cmd+V on macOS
181 if key.modifiers.contains(KeyModifiers::SUPER) {
182 return true;
183 }
184
185 // Ctrl+V on Linux/Windows
186 key.modifiers.contains(KeyModifiers::CONTROL)
187 }
188
189 /// `Ctrl+H` is the legacy ASCII backspace many terminals still emit
190 /// when the user presses Backspace. Disallows Alt/Super so it doesn't
191 /// shadow window-management combos.
192 pub(super) fn is_ctrl_h_backspace(key: &KeyEvent) -> bool {
193 matches!(key.code, KeyCode::Char('h'))
194 && key.modifiers.contains(KeyModifiers::CONTROL)
195 && !key.modifiers.contains(KeyModifiers::ALT)
196 && !key.modifiers.contains(KeyModifiers::SUPER)
197 }
198
199 #[cfg(test)]
200 mod tests {
201 use super::*;
202
203 #[test]
204 fn enhanced_keyboard_clipboard_events_are_accepted_cross_platform() {
205 let mac_copy = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER);
206 let mac_paste = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER);
207 let linux_copy = KeyEvent::new(
208 KeyCode::Char('c'),
209 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
210 );
211 let linux_paste = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
212
213 assert!(is_copy_shortcut(&mac_copy));
214 assert!(is_paste_shortcut(&mac_paste));
215 assert!(is_copy_shortcut(&linux_copy));
216 assert!(is_paste_shortcut(&linux_paste));
217 }
218
219 #[test]
220 fn ctrl_o_and_ctrl_shift_o_have_stable_distinct_routes() {
221 let reasoning = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL);
222 // Crossterm's native Windows decoder applies Caps Lock to the
223 // character but does not expose Caps Lock as a modifier.
224 let reasoning_caps_lock = KeyEvent::new(KeyCode::Char('O'), KeyModifiers::CONTROL);
225 let editor_lower = KeyEvent::new(
226 KeyCode::Char('o'),
227 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
228 );
229 let editor_upper = KeyEvent::new(
230 KeyCode::Char('O'),
231 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
232 );
233
234 for reasoning in [&reasoning, &reasoning_caps_lock] {
235 assert!(is_reasoning_detail_shortcut(reasoning));
236 assert!(!is_turn_inspector_shortcut(reasoning));
237 assert!(!is_external_editor_shortcut(reasoning));
238 }
239 for editor in [&editor_lower, &editor_upper] {
240 assert!(!is_reasoning_detail_shortcut(editor));
241 assert!(!is_turn_inspector_shortcut(editor));
242 assert!(is_external_editor_shortcut(editor));
243 }
244
245 let editor_legacy_fallback = KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE);
246 assert!(is_external_editor_shortcut(&editor_legacy_fallback));
247 }
248
249 #[test]
250 fn turn_inspector_uses_collision_free_ctrl_alt_o() {
251 let turn_inspector = KeyEvent::new(
252 KeyCode::Char('o'),
253 KeyModifiers::CONTROL | KeyModifiers::ALT,
254 );
255 let turn_inspector_caps = KeyEvent::new(
256 KeyCode::Char('O'),
257 KeyModifiers::CONTROL | KeyModifiers::ALT,
258 );
259 for key in [&turn_inspector, &turn_inspector_caps] {
260 assert!(is_turn_inspector_shortcut(key));
261 assert!(!is_reasoning_detail_shortcut(key));
262 assert!(!is_external_editor_shortcut(key));
263 }
264
265 // Must not fire for bare Alt+O (would shadow typing) or Ctrl+Shift+O.
266 let alt_o = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::ALT);
267 let ctrl_shift_o = KeyEvent::new(
268 KeyCode::Char('o'),
269 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
270 );
271 assert!(!is_turn_inspector_shortcut(&alt_o));
272 assert!(!is_turn_inspector_shortcut(&ctrl_shift_o));
273 }
274
275 #[test]
276 fn select_all_accepts_ctrl_shift_a_and_cmd_a_but_not_readline_ctrl_a() {
277 let ctrl_shift_lower = KeyEvent::new(
278 KeyCode::Char('a'),
279 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
280 );
281 let ctrl_shift_upper = KeyEvent::new(
282 KeyCode::Char('A'),
283 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
284 );
285 let cmd_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER);
286 assert!(is_select_all_shortcut(&ctrl_shift_lower));
287 assert!(is_select_all_shortcut(&ctrl_shift_upper));
288 assert!(is_select_all_shortcut(&cmd_a));
289
290 // Readline home stays readline home.
291 let readline_ctrl_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
292 assert!(!is_select_all_shortcut(&readline_ctrl_a));
293 // Alt combinations and plain typing never select-all.
294 let alt_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT);
295 let plain_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
296 assert!(!is_select_all_shortcut(&alt_a));
297 assert!(!is_select_all_shortcut(&plain_a));
298 }
299
300 #[test]
301 fn tool_details_hint_uses_the_routed_chord_not_plain_typing() {
302 let label = tool_details_shortcut_label();
303
304 assert_eq!(label, crate::tui::shell_key_routing::tool_details_chord());
305 assert_ne!(label, "v");
306 assert_eq!(
307 tool_details_shortcut_action_hint("output"),
308 format!("{label}:output")
309 );
310 }
311
312 /// #3256: every surface that advertises tool details must name the chord
313 /// that `is_tool_details_shortcut` actually handles — the help catalog,
314 /// shell binding catalog, and in-transcript hint share one source of
315 /// truth so bare-`v` "details" copy cannot regress while bare `v` types `v`.
316 #[test]
317 fn tool_details_hint_tracks_keybinding_catalog_and_handler() {
318 use crate::localization::MessageId;
319 use crate::tui::keybindings::KEYBINDINGS;
320 use crate::tui::shell_key_routing::{
321 ShellBindingId, binding, is_tool_details_shortcut, tool_details_chord,
322 };
323 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
324
325 let catalog_chords: Vec<&str> = KEYBINDINGS
326 .iter()
327 .filter(|entry| entry.description_id == MessageId::KbSelectedDetails)
328 .map(|entry| entry.chord)
329 .collect();
330 assert_eq!(catalog_chords, vec!["Alt+V"]);
331 assert_eq!(binding(ShellBindingId::ToolDetails).catalog_chord, "Alt+V");
332 assert_eq!(binding(ShellBindingId::ToolDetails).footer_chord, "Alt+V");
333
334 let label = tool_details_shortcut_label();
335 assert_eq!(label, tool_details_chord());
336 assert!(
337 label == "Alt+V" || label == "⌥V",
338 "details hint must advertise Alt+V / ⌥V, got {label}"
339 );
340 assert!(!label.eq_ignore_ascii_case("v"));
341 let details_hint = tool_details_shortcut_action_hint("details");
342 assert_eq!(details_hint, format!("{label}:details"));
343 assert!(!details_hint.starts_with('v'));
344
345 let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);
346 let alt_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT);
347 assert!(!is_tool_details_shortcut(&plain_v));
348 assert!(is_tool_details_shortcut(&alt_v));
349 }
350 }
351
351 lines RUST