返回 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 /// Run `/update install` without leaving the TUI: `Ctrl+Shift+U`.
142 ///
143 /// Distinct from readline `Ctrl+U` (clear the composer line) exactly the
144 /// way `Ctrl+Shift+A` / `Ctrl+Shift+E` / `Ctrl+Shift+O` are distinct from
145 /// their unshifted forms, and like them requires an enhanced-keyboard
146 /// terminal to report the Shift modifier. On macOS the event loop's
147 /// modifier normalization maps `Cmd+Shift+U` onto this chord; the predicate
148 /// itself still rejects a raw SUPER modifier so Linux/Windows meta chords
149 /// never collide with window-management shortcuts.
150 pub(super) fn is_update_install_shortcut(key: &KeyEvent) -> bool {
151 let is_u = matches!(key.code, KeyCode::Char('u') | KeyCode::Char('U'));
152 is_u && key.modifiers.contains(KeyModifiers::CONTROL)
153 && key.modifiers.contains(KeyModifiers::SHIFT)
154 && !key
155 .modifiers
156 .intersects(KeyModifiers::ALT | KeyModifiers::SUPER)
157 }
158
159 /// Modifier predicate for the v0.8.30 family of `Alt+<key>` transcript-
160 /// nav shortcuts (`Alt+G` / `Alt+[` / `Alt+]` / `Alt+?` / `Alt+L`). Requires
161 /// `Alt` and disallows `Ctrl` / `Super` so the
162 /// bindings don't collide with platform clipboard / window-management
163 /// shortcuts. `Shift` is permitted so the capital-letter forms work on
164 /// any keyboard layout that produces them as `Alt+Shift+key`.
165 ///
166 /// Plain `Char` events (no modifier, or modifier=`Shift` alone for the
167 /// uppercase form) fall through to text insertion, which is the whole
168 /// point — typing "good morning" no longer eats the first `g`.
169 pub(super) fn alt_nav_modifiers(modifiers: KeyModifiers) -> bool {
170 modifiers.contains(KeyModifiers::ALT)
171 && !modifiers.contains(KeyModifiers::CONTROL)
172 && !modifiers.contains(KeyModifiers::SUPER)
173 }
174
175 pub(super) fn is_macos_option_v_legacy_key(key: &KeyEvent) -> bool {
176 is_macos_option_v_legacy_key_for_platform(key, cfg!(target_os = "macos"))
177 }
178
179 pub(super) fn is_macos_option_v_legacy_key_for_platform(key: &KeyEvent, is_macos: bool) -> bool {
180 is_macos && key.modifiers.is_empty() && matches!(key.code, KeyCode::Char('\u{221A}'))
181 }
182
183 /// Paste-from-clipboard: accept `Cmd+V`, `Ctrl+V`, or the legacy raw `\u{16}`
184 /// byte some terminals emit. A remote terminal normally consumes its local
185 /// paste chord and sends an `Event::Paste`; accepting both modifier families
186 /// still keeps enhanced-keyboard clients independent of the remote host OS.
187 pub(super) fn is_paste_shortcut(key: &KeyEvent) -> bool {
188 let is_v = matches!(key.code, KeyCode::Char('v') | KeyCode::Char('V'));
189 let is_legacy_ctrl_v = matches!(key.code, KeyCode::Char('\u{16}'));
190 if !is_v && !is_legacy_ctrl_v {
191 return false;
192 }
193
194 if is_legacy_ctrl_v {
195 return true;
196 }
197
198 // Cmd+V on macOS
199 if key.modifiers.contains(KeyModifiers::SUPER) {
200 return true;
201 }
202
203 // Ctrl+V on Linux/Windows
204 key.modifiers.contains(KeyModifiers::CONTROL)
205 }
206
207 /// `Ctrl+H` is the legacy ASCII backspace many terminals still emit
208 /// when the user presses Backspace. Disallows Alt/Super so it doesn't
209 /// shadow window-management combos.
210 pub(super) fn is_ctrl_h_backspace(key: &KeyEvent) -> bool {
211 matches!(key.code, KeyCode::Char('h'))
212 && key.modifiers.contains(KeyModifiers::CONTROL)
213 && !key.modifiers.contains(KeyModifiers::ALT)
214 && !key.modifiers.contains(KeyModifiers::SUPER)
215 }
216
217 #[cfg(test)]
218 mod tests {
219 use super::*;
220
221 #[test]
222 fn enhanced_keyboard_clipboard_events_are_accepted_cross_platform() {
223 let mac_copy = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER);
224 let mac_paste = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER);
225 let linux_copy = KeyEvent::new(
226 KeyCode::Char('c'),
227 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
228 );
229 let linux_paste = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
230
231 assert!(is_copy_shortcut(&mac_copy));
232 assert!(is_paste_shortcut(&mac_paste));
233 assert!(is_copy_shortcut(&linux_copy));
234 assert!(is_paste_shortcut(&linux_paste));
235 }
236
237 #[test]
238 fn ctrl_o_and_ctrl_shift_o_have_stable_distinct_routes() {
239 let reasoning = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL);
240 // Crossterm's native Windows decoder applies Caps Lock to the
241 // character but does not expose Caps Lock as a modifier.
242 let reasoning_caps_lock = KeyEvent::new(KeyCode::Char('O'), KeyModifiers::CONTROL);
243 let editor_lower = KeyEvent::new(
244 KeyCode::Char('o'),
245 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
246 );
247 let editor_upper = KeyEvent::new(
248 KeyCode::Char('O'),
249 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
250 );
251
252 for reasoning in [&reasoning, &reasoning_caps_lock] {
253 assert!(is_reasoning_detail_shortcut(reasoning));
254 assert!(!is_turn_inspector_shortcut(reasoning));
255 assert!(!is_external_editor_shortcut(reasoning));
256 }
257 for editor in [&editor_lower, &editor_upper] {
258 assert!(!is_reasoning_detail_shortcut(editor));
259 assert!(!is_turn_inspector_shortcut(editor));
260 assert!(is_external_editor_shortcut(editor));
261 }
262
263 let editor_legacy_fallback = KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE);
264 assert!(is_external_editor_shortcut(&editor_legacy_fallback));
265 }
266
267 #[test]
268 fn turn_inspector_uses_collision_free_ctrl_alt_o() {
269 let turn_inspector = KeyEvent::new(
270 KeyCode::Char('o'),
271 KeyModifiers::CONTROL | KeyModifiers::ALT,
272 );
273 let turn_inspector_caps = KeyEvent::new(
274 KeyCode::Char('O'),
275 KeyModifiers::CONTROL | KeyModifiers::ALT,
276 );
277 for key in [&turn_inspector, &turn_inspector_caps] {
278 assert!(is_turn_inspector_shortcut(key));
279 assert!(!is_reasoning_detail_shortcut(key));
280 assert!(!is_external_editor_shortcut(key));
281 }
282
283 // Must not fire for bare Alt+O (would shadow typing) or Ctrl+Shift+O.
284 let alt_o = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::ALT);
285 let ctrl_shift_o = KeyEvent::new(
286 KeyCode::Char('o'),
287 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
288 );
289 assert!(!is_turn_inspector_shortcut(&alt_o));
290 assert!(!is_turn_inspector_shortcut(&ctrl_shift_o));
291 }
292
293 #[test]
294 fn ctrl_shift_u_routes_to_update_install_not_readline_clear() {
295 let ctrl_shift_lower = KeyEvent::new(
296 KeyCode::Char('u'),
297 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
298 );
299 let ctrl_shift_upper = KeyEvent::new(
300 KeyCode::Char('U'),
301 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
302 );
303 assert!(is_update_install_shortcut(&ctrl_shift_lower));
304 assert!(is_update_install_shortcut(&ctrl_shift_upper));
305
306 // Readline Ctrl+U stays readline Ctrl+U (clear the composer line).
307 let readline_ctrl_u = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
308 assert!(!is_update_install_shortcut(&readline_ctrl_u));
309 // Bare Shift+U, plain `u`, and meta combos never install an update.
310 let shift_u = KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT);
311 let plain_u = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE);
312 let ctrl_alt_u = KeyEvent::new(
313 KeyCode::Char('u'),
314 KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT,
315 );
316 let super_shift_u = KeyEvent::new(
317 KeyCode::Char('u'),
318 KeyModifiers::SUPER | KeyModifiers::SHIFT,
319 );
320 assert!(!is_update_install_shortcut(&shift_u));
321 assert!(!is_update_install_shortcut(&plain_u));
322 assert!(!is_update_install_shortcut(&ctrl_alt_u));
323 assert!(!is_update_install_shortcut(&super_shift_u));
324 }
325
326 #[test]
327 fn select_all_accepts_ctrl_shift_a_and_cmd_a_but_not_readline_ctrl_a() {
328 let ctrl_shift_lower = KeyEvent::new(
329 KeyCode::Char('a'),
330 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
331 );
332 let ctrl_shift_upper = KeyEvent::new(
333 KeyCode::Char('A'),
334 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
335 );
336 let cmd_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER);
337 assert!(is_select_all_shortcut(&ctrl_shift_lower));
338 assert!(is_select_all_shortcut(&ctrl_shift_upper));
339 assert!(is_select_all_shortcut(&cmd_a));
340
341 // Readline home stays readline home.
342 let readline_ctrl_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
343 assert!(!is_select_all_shortcut(&readline_ctrl_a));
344 // Alt combinations and plain typing never select-all.
345 let alt_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT);
346 let plain_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
347 assert!(!is_select_all_shortcut(&alt_a));
348 assert!(!is_select_all_shortcut(&plain_a));
349 }
350
351 #[test]
352 fn tool_details_hint_uses_the_routed_chord_not_plain_typing() {
353 let label = tool_details_shortcut_label();
354
355 assert_eq!(label, crate::tui::shell_key_routing::tool_details_chord());
356 assert_ne!(label, "v");
357 assert_eq!(
358 tool_details_shortcut_action_hint("output"),
359 format!("{label}:output")
360 );
361 }
362
363 /// #3256: every surface that advertises tool details must name the chord
364 /// that `is_tool_details_shortcut` actually handles — the help catalog,
365 /// shell binding catalog, and in-transcript hint share one source of
366 /// truth so bare-`v` "details" copy cannot regress while bare `v` types `v`.
367 #[test]
368 fn tool_details_hint_tracks_keybinding_catalog_and_handler() {
369 use crate::tui::keybindings::KEYBINDINGS;
370 use crate::tui::shell_key_routing::{
371 ShellBindingId, binding, is_tool_details_shortcut, tool_details_chord,
372 };
373 use codewhale_localization::MessageId;
374 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
375
376 let catalog_chords: Vec<&str> = KEYBINDINGS
377 .iter()
378 .filter(|entry| entry.description_id == MessageId::KbSelectedDetails)
379 .map(|entry| entry.chord)
380 .collect();
381 assert_eq!(catalog_chords, vec!["Alt+V"]);
382 assert_eq!(binding(ShellBindingId::ToolDetails).catalog_chord, "Alt+V");
383 assert_eq!(binding(ShellBindingId::ToolDetails).footer_chord, "Alt+V");
384
385 let label = tool_details_shortcut_label();
386 assert_eq!(label, tool_details_chord());
387 assert!(
388 label == "Alt+V" || label == "⌥V",
389 "details hint must advertise Alt+V / ⌥V, got {label}"
390 );
391 assert!(!label.eq_ignore_ascii_case("v"));
392 let details_hint = tool_details_shortcut_action_hint("details");
393 assert_eq!(details_hint, format!("{label}:details"));
394 assert!(!details_hint.starts_with('v'));
395
396 let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);
397 let alt_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT);
398 assert!(!is_tool_details_shortcut(&plain_v));
399 assert!(is_tool_details_shortcut(&alt_v));
400 }
401 }
402
402 lines RUST