返回 CodeWhale
shell_key_routing.rs
根目录 / crates / tui / src / tui / shell_key_routing.rs
1 //! Shell keyboard bindings for details / context / help.
2 //!
3 //! Footer hints, help catalog chords, and live handlers must agree on one
4 //! source. Printable characters always belong to the composer: bare `v`
5 //! types `v` in every focus state — work surface, transcript selection,
6 //! panel, or modal (TUI-DOG-002). Details/output fires only on
7 //! Option+V / Alt+V, and macOS renders the label as `⌥V`, never `Alt`/`Cmd`.
8 //! Help is `F1` (with `/help`); `Ctrl+/` stays as a secondary fallback.
9 //! `Alt+?` and `Alt+C` are still accepted where terminals deliver them but
10 //! are never advertised until proven in real terminals (TUI-DOG-003);
11 //! `/context` is the guaranteed context path.
12 //! Ambiguous macOS Option glyphs (`ç` / `¿`) remain text: terminals do not
13 //! identify whether they came from Option or from a user's keyboard layout.
14
15 use std::borrow::Cow;
16
17 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
18
19 use crate::tui::key_shortcuts;
20
21 /// Stable binding ids shared by handlers, footer hints, and help catalog.
22 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
23 pub enum ShellBindingId {
24 ToolDetails,
25 ContextInspector,
26 Help,
27 }
28
29 /// One advertised binding with the portable catalog chord and focus rules.
30 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
31 pub struct ShellBinding {
32 pub id: ShellBindingId,
33 /// Chord shown in help / documentation (portable Alt form; macOS
34 /// substitutes `⌥` at render time via [`display_chord`]).
35 pub catalog_chord: &'static str,
36 /// Compact footer chord when this binding is advertised.
37 pub footer_chord: &'static str,
38 }
39
40 /// Canonical shell bindings. Handlers and chrome read from here.
41 pub const SHELL_BINDINGS: &[ShellBinding] = &[
42 ShellBinding {
43 id: ShellBindingId::ToolDetails,
44 catalog_chord: "Alt+V",
45 footer_chord: "Alt+V",
46 },
47 ShellBinding {
48 id: ShellBindingId::ContextInspector,
49 // `/context` is the guaranteed path; Alt+C stays an unadvertised
50 // handler until proven in Cursor/Terminal.app/iTerm2/tmux/PTY.
51 catalog_chord: "/context",
52 footer_chord: "/context",
53 },
54 ShellBinding {
55 id: ShellBindingId::Help,
56 // `/help` also opens this; Ctrl+/ is the secondary fallback.
57 catalog_chord: "F1 / Ctrl+/",
58 footer_chord: "F1",
59 },
60 ];
61
62 #[must_use]
63 pub fn binding(id: ShellBindingId) -> &'static ShellBinding {
64 SHELL_BINDINGS
65 .iter()
66 .find(|binding| binding.id == id)
67 .expect("shell binding catalog is exhaustive")
68 }
69
70 /// Platform-aware chord for opening complete tool or approval details.
71 #[must_use]
72 pub fn tool_details_chord() -> Cow<'static, str> {
73 display_chord(binding(ShellBindingId::ToolDetails).footer_chord)
74 }
75
76 /// Render a portable `Alt+X` chord for the current platform. macOS normally
77 /// shows `⌥X`; ASCII-safe terminals retain the portable `Alt+X` spelling.
78 #[must_use]
79 pub fn display_chord(chord: &'static str) -> Cow<'static, str> {
80 display_chord_for_platform_and_ascii(
81 chord,
82 cfg!(target_os = "macos"),
83 crate::tui::color_compat::ascii_safe_enabled(),
84 )
85 }
86
87 #[cfg(test)]
88 #[must_use]
89 pub fn display_chord_for_platform(chord: &'static str, is_macos: bool) -> Cow<'static, str> {
90 display_chord_for_platform_and_ascii(chord, is_macos, false)
91 }
92
93 fn display_chord_for_platform_and_ascii(
94 chord: &'static str,
95 is_macos: bool,
96 ascii_safe: bool,
97 ) -> Cow<'static, str> {
98 if ascii_safe {
99 return Cow::Borrowed(chord);
100 }
101 if !is_macos {
102 return Cow::Borrowed(chord);
103 }
104 let rendered = chord.replace("Alt+", "⌥").replace("F1", "fn+F1");
105 if rendered == chord {
106 Cow::Borrowed(chord)
107 } else {
108 Cow::Owned(rendered)
109 }
110 }
111
112 /// Footer right-hand action hints. Placeholders (`{output}`, `{context}`,
113 /// `{keys}`) are localized by the caller.
114 #[must_use]
115 pub fn footer_action_hints(include_context: bool) -> String {
116 footer_action_hints_for_platform_and_ascii(
117 include_context,
118 cfg!(target_os = "macos"),
119 crate::tui::color_compat::ascii_safe_enabled(),
120 )
121 }
122
123 #[cfg(test)]
124 #[must_use]
125 pub fn footer_action_hints_for_platform(include_context: bool, is_macos: bool) -> String {
126 footer_action_hints_for_platform_and_ascii(include_context, is_macos, false)
127 }
128
129 fn footer_action_hints_for_platform_and_ascii(
130 include_context: bool,
131 is_macos: bool,
132 ascii_safe: bool,
133 ) -> String {
134 let details = display_chord_for_platform_and_ascii(
135 binding(ShellBindingId::ToolDetails).footer_chord,
136 is_macos,
137 ascii_safe,
138 );
139 let help = display_chord_for_platform_and_ascii(
140 binding(ShellBindingId::Help).footer_chord,
141 is_macos,
142 ascii_safe,
143 );
144 if include_context {
145 format!(
146 "{details}:{{output}} · {}:{{context}} · {help}:{{keys}}",
147 binding(ShellBindingId::ContextInspector).footer_chord
148 )
149 } else {
150 format!("{details}:{{output}} · {help}:{{keys}}")
151 }
152 }
153
154 /// Details/output opens only on Option+V (macOS legacy `√`) or Alt+V.
155 /// Bare `v` always types `v` — never a shortcut, in any focus state.
156 #[must_use]
157 pub fn is_tool_details_shortcut(key: &KeyEvent) -> bool {
158 if key_shortcuts::is_macos_option_v_legacy_key(key) {
159 return true;
160 }
161 matches!(key.code, KeyCode::Char('v') | KeyCode::Char('V'))
162 && key_shortcuts::alt_nav_modifiers(key.modifiers)
163 }
164
165 #[must_use]
166 pub fn is_context_inspector_shortcut(key: &KeyEvent) -> bool {
167 matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
168 && key_shortcuts::alt_nav_modifiers(key.modifiers)
169 }
170
171 #[must_use]
172 pub fn is_help_shortcut(key: &KeyEvent) -> bool {
173 if matches!(key.code, KeyCode::F(1)) {
174 return true;
175 }
176 // Windows delivers AltGr as Ctrl+Alt, so a layout-emitted glyph (e.g.
177 // AltGr+Q typing '/' on ABNT2) would satisfy a bare CONTROL check.
178 // AltGr chords are text, never shortcuts (#4723).
179 let altgr = crate::tui::widgets::key_hint::is_altgr(key.modifiers);
180 if matches!(key.code, KeyCode::Char('/'))
181 && key.modifiers.contains(KeyModifiers::CONTROL)
182 && !altgr
183 {
184 return true;
185 }
186 // Some legacy terminal stacks encode Ctrl+/ as the ASCII unit separator,
187 // which crossterm reports as Ctrl+7 or Ctrl+_. Accept both portable
188 // decodings so the documented fallback remains real.
189 if matches!(key.code, KeyCode::Char('7') | KeyCode::Char('_'))
190 && key.modifiers.contains(KeyModifiers::CONTROL)
191 && !altgr
192 {
193 return true;
194 }
195 // Alt+? still opens help where the terminal delivers it, but it is not
196 // advertised anywhere (TUI-DOG-003).
197 matches!(key.code, KeyCode::Char('?')) && key_shortcuts::alt_nav_modifiers(key.modifiers)
198 }
199
200 #[must_use]
201 pub fn is_settings_shortcut(key: &KeyEvent) -> bool {
202 matches!(key.code, KeyCode::F(2)) && key.modifiers.is_empty()
203 }
204
205 #[cfg(test)]
206 mod tests {
207 use super::*;
208
209 #[test]
210 fn bare_v_is_never_a_shortcut_in_any_state() {
211 // TUI-DOG-002: bare `v` always types `v`; there is no focus state in
212 // which it opens details, so the matcher takes no focus argument.
213 let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);
214 assert!(!is_tool_details_shortcut(&plain_v));
215 let plain_upper_v = KeyEvent::new(KeyCode::Char('V'), KeyModifiers::SHIFT);
216 assert!(!is_tool_details_shortcut(&plain_upper_v));
217 }
218
219 #[test]
220 fn alt_v_and_macos_option_v_open_details() {
221 let alt_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT);
222 assert!(is_tool_details_shortcut(&alt_v));
223 let alt_upper_v = KeyEvent::new(KeyCode::Char('V'), KeyModifiers::ALT);
224 assert!(is_tool_details_shortcut(&alt_upper_v));
225 }
226
227 #[test]
228 fn details_label_is_option_glyph_on_macos_and_alt_elsewhere() {
229 assert_eq!(display_chord_for_platform("Alt+V", true), "⌥V");
230 assert_eq!(display_chord_for_platform("Alt+V", false), "Alt+V");
231 let macos = footer_action_hints_for_platform(true, true);
232 assert!(macos.starts_with("⌥V:"), "{macos}");
233 assert!(!macos.contains("Alt"), "{macos}");
234 assert!(!macos.contains("Cmd"), "{macos}");
235 let other = footer_action_hints_for_platform(true, false);
236 assert!(other.starts_with("Alt+V:"), "{other}");
237 }
238
239 #[test]
240 fn ascii_safe_macos_hints_keep_portable_chords() {
241 assert_eq!(
242 display_chord_for_platform_and_ascii("Alt+V", true, true),
243 "Alt+V"
244 );
245 let hints = footer_action_hints_for_platform_and_ascii(true, true, true);
246 assert!(hints.starts_with("Alt+V:"), "{hints}");
247 assert!(hints.contains("F1:"), "{hints}");
248 assert!(!hints.contains('⌥'), "{hints}");
249 }
250
251 #[test]
252 fn footer_hints_never_advertise_bare_v_alt_question_or_alt_c() {
253 for is_macos in [true, false] {
254 for include_context in [true, false] {
255 let hints = footer_action_hints_for_platform(include_context, is_macos);
256 assert!(!hints.starts_with("v:"), "{hints}");
257 assert!(!hints.contains(" v:"), "{hints}");
258 assert!(!hints.contains("Alt+?"), "{hints}");
259 assert!(!hints.contains("Alt+C"), "{hints}");
260 assert!(hints.contains("F1:"), "{hints}");
261 if is_macos {
262 assert!(hints.contains("fn+F1:"), "{hints}");
263 }
264 if include_context {
265 assert!(hints.contains("/context:"), "{hints}");
266 }
267 }
268 }
269 }
270
271 #[test]
272 fn help_accepts_f1_ctrl_slash_and_unadvertised_fallbacks() {
273 assert!(is_help_shortcut(&KeyEvent::new(
274 KeyCode::F(1),
275 KeyModifiers::NONE
276 )));
277 assert!(is_help_shortcut(&KeyEvent::new(
278 KeyCode::Char('/'),
279 KeyModifiers::CONTROL
280 )));
281 assert!(is_help_shortcut(&KeyEvent::new(
282 KeyCode::Char('7'),
283 KeyModifiers::CONTROL
284 )));
285 assert!(is_help_shortcut(&KeyEvent::new(
286 KeyCode::Char('_'),
287 KeyModifiers::CONTROL
288 )));
289 // Unadvertised but accepted where the terminal delivers them.
290 assert!(is_help_shortcut(&KeyEvent::new(
291 KeyCode::Char('?'),
292 KeyModifiers::ALT
293 )));
294 let inverted_question = KeyEvent::new(KeyCode::Char('\u{00bf}'), KeyModifiers::NONE);
295 assert!(!is_help_shortcut(&inverted_question));
296 }
297
298 #[test]
299 fn altgr_slash_types_text_instead_of_opening_help() {
300 // Windows encodes AltGr as Ctrl+Alt: AltGr+Q on ABNT2 delivers '/'
301 // with CONTROL|ALT and must reach the composer as text (#4723).
302 let altgr_slash = KeyEvent::new(
303 KeyCode::Char('/'),
304 KeyModifiers::CONTROL | KeyModifiers::ALT,
305 );
306 let altgr_seven = KeyEvent::new(
307 KeyCode::Char('7'),
308 KeyModifiers::CONTROL | KeyModifiers::ALT,
309 );
310 if cfg!(windows) {
311 assert!(!is_help_shortcut(&altgr_slash));
312 assert!(!is_help_shortcut(&altgr_seven));
313 } else {
314 // Elsewhere Ctrl+Alt is a deliberate chord and keeps working.
315 assert!(is_help_shortcut(&altgr_slash));
316 assert!(is_help_shortcut(&altgr_seven));
317 }
318 // Plain Ctrl+/ still opens help everywhere.
319 assert!(is_help_shortcut(&KeyEvent::new(
320 KeyCode::Char('/'),
321 KeyModifiers::CONTROL
322 )));
323 }
324
325 #[test]
326 fn settings_accepts_only_plain_f2() {
327 assert!(is_settings_shortcut(&KeyEvent::new(
328 KeyCode::F(2),
329 KeyModifiers::NONE
330 )));
331 assert!(!is_settings_shortcut(&KeyEvent::new(
332 KeyCode::F(2),
333 KeyModifiers::SHIFT
334 )));
335 assert!(!is_settings_shortcut(&KeyEvent::new(
336 KeyCode::F(1),
337 KeyModifiers::NONE
338 )));
339 }
340
341 #[test]
342 fn context_accepts_explicit_alt_c_without_stealing_layout_characters() {
343 let alt_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT);
344 assert!(is_context_inspector_shortcut(&alt_c));
345 let cedilla = KeyEvent::new(KeyCode::Char('\u{00e7}'), KeyModifiers::NONE);
346 assert!(!is_context_inspector_shortcut(&cedilla));
347 }
348
349 #[test]
350 fn catalog_chords_match_final_contract() {
351 assert_eq!(binding(ShellBindingId::Help).catalog_chord, "F1 / Ctrl+/");
352 assert_eq!(
353 binding(ShellBindingId::ContextInspector).catalog_chord,
354 "/context"
355 );
356 assert_eq!(binding(ShellBindingId::ToolDetails).catalog_chord, "Alt+V");
357 for binding in SHELL_BINDINGS {
358 assert!(!binding.catalog_chord.contains("Alt+?"));
359 assert_ne!(binding.catalog_chord, "v");
360 assert!(!binding.catalog_chord.starts_with("v /"));
361 assert!(!binding.footer_chord.contains("Alt+?"));
362 assert_ne!(binding.footer_chord, "v");
363 }
364 }
365 }
366
366 lines RUST